From 1b57a572182d9f6f76cb6169eec7b65495be5910 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:33:57 +0900 Subject: [PATCH 01/87] chore(release): open dev at 2.61.0 before releasing 2.60.0 (#5247) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ed4bb03a82a..7278f576ab6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.60.0", + "version": "2.61.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", From 64bad3eb4fb07ce991e29273ad308c3afcef4b33 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 13:09:47 +0900 Subject: [PATCH 02/87] docs(devlog): record the 2.60.0 regression, promotion and publication run (#5251) --- .../010_execution.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 devlog/_plan/260920_regression_release/010_execution.md diff --git a/devlog/_plan/260920_regression_release/010_execution.md b/devlog/_plan/260920_regression_release/010_execution.md new file mode 100644 index 00000000000..39b8ca8e61c --- /dev/null +++ b/devlog/_plan/260920_regression_release/010_execution.md @@ -0,0 +1,90 @@ +# 2.60.0 regression and release execution + +Status: IN PROGRESS. This file records what was actually executed, with exact commits, runs and +dispositions. It is not a completion claim beyond the evidence listed here. + +## Candidate freeze + +| Branch | Before | After | +| --- | --- | --- | +| dev | `12cb129d424d1a67319c1e7a761ba5474fdc48a4` | `1b57a572182d9f6f76cb6169eec7b65495be5910` (2.61.0) | +| main | `134c92a01b120162f00c7275189cc47858720379` (2.59.0) | `7c625fc9755c9824653ab944190e243091a2c85c` (2.60.0) | +| preview | `48e1ddba0bb8da9ad39e32f8e20c1e4d7f1794da` (2.58.0) | `84c4f014c8da51ff50c3e8b64f2d82b9ee3792da` (2.60.0) | + +The released tree is `015c67c46aaf16d4319543c8941c6dbec9887ae6`, the `dev` head after the last +blocking repair and before the version pre-move. + +## Dev-tip regression failure and repair + +Run `35485314835` at `12cb129d42` failed on Linux `test 2/4` and macOS `macos 2/2` with the same +single case, `sanitizeEncryptedContentInPlace > plaintext parked in encrypted slots becomes +input_text; real blobs survive`, reporting `Expected: 2, Received: 3` at +`tests/codex-integration/multi-agent-compat.test.ts:1392`. Every other producer and both keyring +and docker legs passed, so the aggregate `ci` check failed on that one assertion. + +This is the merge-union class `AGENTS.md` describes. #5239 replaced `looksLikeBackendCiphertext` +with `isStructurallyValidFernetToken`, which requires canonical base64url, length at least 100, a +`0x80` version byte and a 16-byte-aligned ciphertext. The case predates that change and minted its +surviving blob as `"gAAAAAB".padEnd(120, "Qw1_-=")`, which embeds `=` mid-token and is therefore no +longer a valid token, so the sanitizer correctly lowered it as a third rewrite. #5239's own tests +were updated; this one in another domain directory was not. + +#5246 mints the blob with the `fernetFixture()` helper already defined in the same `describe` — +a 73-byte payload with `raw[0] = 0x80` encoding to a 100-character canonical token, giving +`decoded.length - 57 === 16`. The classifier is untouched, the rewrite count returns to 2 and the +surviving slot stays byte-identical. Merged to `dev` as `015c67c46aaf16d4319543c8941c6dbec9887ae6`. + +## Integration sequence + +1. #5242 archived the campaign unit to `_fin`; merged as `d3d637912ab5080e0d54ef60db273951746d0aa9`. +2. #5246 repaired the fixture; merged as `015c67c46aaf16d4319543c8941c6dbec9887ae6`. +3. `release/2.60.0` was cut at that head, before any version move. +4. `dev-version-bump.yml` was dispatched with `intended-version=2.60.0`. The first dispatch from + `dev` was refused by the workflow's default-ref guard; the dispatch from `main` + (`35486732967`) opened #5247, merged as `1b57a572182d9f6f76cb6169eec7b65495be5910`, so `dev` + outranks the release at 2.61.0. +5. #5249 promoted `release/2.60.0` to `main` as merge commit `7c625fc9755c9824653ab944190e243091a2c85c`. +6. #5250 brought `preview` onto the same tree as `84c4f014c8da51ff50c3e8b64f2d82b9ee3792da`; + `git diff` against `main` was empty before the merge and after it. + +The maintainer directed admin integration without waiting for per-pull-request CI on the repair and +promotion pull requests. That timing direction is recorded here rather than presented as a +completed per-PR pass. + +## Publication gates and full regression evidence + +`release.yml` refuses to publish without a successful push-event `ci.yml` run for the exact release +commit; a pull-request run does not qualify. The first dispatch (`35486936693`) failed on that gate +while the `main` push run was still queued. `service-lifecycle.yml` was already satisfied for the +same commit by run `35486928648`. + +The full regression from the request-time `main` baseline through the released candidate is push +run `35486928618` at `7c625fc9755c9824653ab944190e243091a2c85c`, conclusion SUCCESS. Every +event-requested producer passed: `changes`, all four Linux `test` shards, both macOS shards, +`storage policy`, `api usage`, `gates`, `structure gate`, `docker smoke`, `docs site build`, all +three `keyring` legs and all three `npm-global` legs. The nine-shard Windows matrix and +`macos control` are workflow-dispatch lanes that this event does not request, so their skipped +placeholders are applicability, not execution. + +## Publication + +Release run `35488151017` at the same commit completed SUCCESS with every step green, including +`Require successful Cross-platform CI for this commit`, `Require dev to be ready for this release`, +`Refuse a release the current tag set already outranks` and `Publish (or dry-run)` with +`dry-run=false`. + +npm accepted `+ @bitkyc08/opencodex@2.60.0` on dist-tag `latest` with public access and a signed +provenance statement recorded in the sigstore transparency log at index `2894025266`. Tarball +`bitkyc08-opencodex-2.60.0.tgz`, shasum `651613e7536c33be936ac387a0a9f38ff290c4cc`, 1436 files. + +Tag `v2.60.0` points at `7c625fc9755c9824653ab944190e243091a2c85c` and the GitHub release was +published at 2026-09-20T04:05:15Z, not a draft and not a prerelease. + +Registry propagation was still pending at the time of writing: npm reported "Your package is being +processed and may take a few minutes to become available", the workflow's bounded six-attempt smoke +ended `verification=pending`, and direct reads of +`https://registry.npmjs.org/@bitkyc08%2fopencodex` still showed `latest` at 2.59.0 about fifteen +minutes after publication. The publish itself is acknowledged and must not be republished; the +remaining check is a later registry read confirming `2.60.0` under `latest`. + +Local suites, typecheck, builds, installs and runtime execution were not run in this lane. From 0613aaec17b49ac6e2ea6b163792ad877361555c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 13:45:42 +0900 Subject: [PATCH 03/87] docs(devlog): open the meaning-preservation and request-scoped safety batch (#5257) --- .../000_plan.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/000_plan.md diff --git a/devlog/_plan/260920_meaning_preservation_batch/000_plan.md b/devlog/_plan/260920_meaning_preservation_batch/000_plan.md new file mode 100644 index 00000000000..5003dc1a5a1 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/000_plan.md @@ -0,0 +1,73 @@ +# Meaning preservation and request-scoped safety batch + +Status: OPEN. Opened against `main` 2.60.0 (`7c625fc9755c9824653ab944190e243091a2c85c`) and the +current `dev` head. This unit covers the first six items of the post-2.60.0 assessment: the two +safety fixes and the four meaning-preservation defects. Scope beyond those six is explicitly out. + +## Why these six + +The assessment scored meaning preservation lowest of the six axes. The shared failure mode is that +a request arrives carrying an explicit constraint — which tools may be called, how a tool is +declared, an attached document, where an instruction sits in the conversation — and the proxy +returns a normal HTTP success after silently dropping it. A test that only asserts a successful +tool call or a 200 response cannot see any of them. + +The governing rule for every item in this unit: + +> On a supported path, preserve it. On an unsupported path, refuse it or apply the conversion +> policy the operator chose. Never drop it quietly and return as if the request was honored. + +## Delivery topology + +Each lane delivers **one branch with ordered commits and one pull request** against `dev`. No +GitHub native stack and no chain of child pull requests. Where an existing contributor pull +request already covers part of a lane's scope, the lane carries that work into its own branch with +a `Co-authored-by` trailer naming the original author, and the superseded pull request is closed by +the coordinator only after the lane lands. Carrying without the trailer is not acceptable: +`missing_coauthor_credit` in `.github/scripts/pr-carry-attribution.cjs` exists because +`CREDITS.md` already lists 27 landings that lost their author. + +## Lanes + +### Lane A — meaning preservation on the request path + +| Item | Contract to restore | +| --- | --- | +| #5211 | Caller-specified `allowed_tools` and `parallel_tool_calls: false` survive the Chat Completions path from inbound parse to the actual outbound request. | +| #5210 | Tool declaration `strict` and `allowed_callers` reach destinations that support them; an unsupported destination refuses rather than silently widening the declaration. | +| #5212 | Inline document bytes survive the inbound parse into the internal representation and outbound, so a title-only forward is never reported as a success. | +| #5213 | A `developer` message keeps its chronological position. Role conversion to `system` is a separate, explicitly recorded decision with its own acceptance, not a side effect of placement. | + +#5237 is a correct narrow fix for the #5213 position problem and is not the whole of role +preservation. Lane A carries it with attribution and keeps position and role as two distinct +acceptance conditions. + +### Lane B — request-scoped transport and managed-write safety + +| Item | Contract to restore | +| --- | --- | +| #5087 | DNS pinning and transport selection are decided by whether a proxy actually applies to *this request*, not by whether one is configured. Scheme mismatch, `NO_PROXY` and DNS failure must not produce an unintended unpinned direct connection. | +| #5241 | A managed configuration write never follows a terminal symlink to another file, including under `apply`, `refresh`, `disable` and `restore` races. | + +Both existing pull requests are authored by the same contributor and are carried with attribution. + +## Regression discipline + +Behaviour changes in `src/` need focused regressions next to the existing tests for that subsystem. +Before any push, check the union-defect classes `AGENTS.md` records, because exact-head CI cannot +see a defect that exists only in the union of two branches: + +- the file-size ratchet only moves downward, so a new case goes in a sibling file registered in + both `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`; +- anything exhaustive over a union — locale catalogs, `satisfies Record`, hand-written + rosters, counts in generated documentation — must be derived rather than restated. + +The 2.60.0 release was blocked by exactly this class: #5239 tightened Fernet validation while an +older case in another domain directory still minted its fixture the loose way. + +## Execution constraints + +No local suites, individual tests, typecheck, build, install or live `ocx` execution. Verification +is static source review plus exact-head hosted CI. Branch pushes use `--no-verify`. Only the +coordinator merges and closes issues. Public artifacts stay English and name no other repository or +model. From b9483b3b510f9a8d99d465282517f7bf91678de3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 13:54:33 +0900 Subject: [PATCH 04/87] docs(devlog): open phase 2 of the post-2.60.0 consolidation batch (#5259) --- .../010_phase2.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/010_phase2.md diff --git a/devlog/_plan/260920_meaning_preservation_batch/010_phase2.md b/devlog/_plan/260920_meaning_preservation_batch/010_phase2.md new file mode 100644 index 00000000000..a6a6a2ff041 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/010_phase2.md @@ -0,0 +1,84 @@ +# Phase 2 — the ten consolidation bundles + +Status: OPEN. Phase 1 (items 1-6) runs in lanes A and B under [000_plan.md](000_plan.md). This file +opens items 7-16 of the post-2.60.0 assessment. These are **not ten new pull requests**. Each bundle +is a unit of existing issues and pull requests to reuse, with only the shared part reviewed +together. + +## Delivery topology is unchanged + +One branch, ordered commits, one pull request to `dev` per lane. No native stack, no child pull +request chain. Carried contributor work needs a `Co-authored-by` trailer in a branch commit; +superseded pull requests are closed by the coordinator only after the lane lands. + +## Why these groupings and not one bundle per lane + +Bundles 7 and 14 both want the same substrate. Item 7 divides a failure into pre-header, +headers-only, protocol prelude, semantic output, side effect and terminal, and decides resend +permission per stage. Item 14 wants a logical request to attempt to physical send to terminal +record with one cause dictionary. Two lanes defining that separately would typecheck on each branch +and contradict each other in the merge — the exact class that blocked 2.60.0. They stay in one lane. + +Bundles 8 and 9 are the same question asked twice: an observation attributed to an account or +credential generation must not survive its replacement. A refusal learned from the previous account +and a warm cache binding dropped on a threshold hint are the same attribution defect at different +layers. + +Bundle 13 consumes the request-scoped route decision that lane B is building for #5087. Starting it +before lane B lands would fork that authority, so it is scheduled after. + +## Lanes + +| Lane | Bundles | Existing items to reuse | +| --- | --- | --- | +| C | 7 retry stage table, 14 one event model | #4942, #4989, #5245, #2366, #3748, #3983, #5063; issues #4191, #5180 | +| D | 8 account and credential generation, 9 cache affinity and diagnostics | #5214, #5145, #5229, #5209, #4793; issues #3375, #5178, #3433, #3765 | +| E | 10 Devin output budget, 11 adapter queue memory, 12 per-key permission | #5189, #5182; issues #5190, #5049 | +| F (after B) | 13 per-provider egress, 15 CodeBuddy and native wire | #3901, #5148, #5147, #5188; issues #2894, #5146, #5097, #5096 | +| G (after B) | 16 onboarding, update and screen consolidation | #5016, #4560, #5068; issues #2811, #5215, #5216 | + +## Ownership boundaries between concurrent lanes + +These exist because the lanes share a checkout-independent surface and would otherwise collide. + +- Lane C owns send accounting: `sendCount`, request-wide send budget and the stage and cause + vocabulary. Lanes D and E consume it and do not redefine it. +- Lane D owns #4793 and every per-model cache view. Lane C derives cache projections from the + recorder without editing that surface. +- Lane E owns the adapter event queue budget and the Devin and coding-agent limits. It does not + touch retry classification. +- A collision that cannot be resolved inside these boundaries goes to the coordinator rather than + being settled unilaterally in one branch. + +## Acceptance that is easy to fake and must not be + +Each bundle has a completion condition that a passing request does not demonstrate. + +- 7: an uncertain resend after output or a side effect is never automatically permitted; 429, quota, + policy refusal and ciphertext refusal stay distinguishable; the provider's stated reason and the + actual send count agree, with no duplicated parent and child counter. +- 8: a refusal or capability observation from a replaced account does not transfer; a cancelled + request's late refresh does not overwrite another request's binding; false, unknown and absent + stay distinct. +- 9: passing a threshold alone does not drop a warm binding, while real exhaustion does; input + change, account change and transformation change are distinguishable; a prefix fingerprint never + becomes a public or durable correlation key. +- 10: with the caller omitting a limit, the configured effective output cap reaches the wire, and an + explicit small cap survives; the history ceiling keeps its own meaning. +- 11: slow consumers, one large event, accumulated coalescing and a cancel race all stay bounded + with no unreleased counter, and a normal long stream is not capped by total length. +- 12: an alias, combo child, fallback or compact route cannot reach a forbidden model or provider; + filtering `/models` is not completion; an inference key never gains management authority. + +## Release shape + +The first stabilization release carries items 1-6 plus only the small, reproduced fixes from 8, 10 +and 11. Items 7, 9, 12, 13 and 14 form the second group. Items 15 and 16 are the optional extension +and do not precede the fidelity work. A new large control plane, a full manager rewrite and a +multi-tenant conversion stay out of this stabilization and are not closed as unwanted. + +## Execution constraints + +Unchanged from phase 1: no local suites, individual tests, typecheck, build, install or live `ocx` +execution; verification is static source review plus exact-head hosted CI; pushes use +`--no-verify`; only the coordinator merges and closes issues. From cb234aa15ec3091388df27473cfc0601677d787b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:19:38 +0900 Subject: [PATCH 05/87] docs(devlog): open the Codex sign-in lockout incident lane (#5263) --- .../020_incident_5261.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/020_incident_5261.md diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_incident_5261.md b/devlog/_plan/260920_meaning_preservation_batch/020_incident_5261.md new file mode 100644 index 00000000000..400c8c79a4f --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_incident_5261.md @@ -0,0 +1,48 @@ +# Incident lane — Codex sign-in lockout after an applied integration (#5261) + +Status: OPEN, dispatched out of band and ahead of the phase 2 bundles. This is a user-reported +incident, not a roadmap item. + +## What was reported + +A Windows 11 user on 2.59.0 configured the Codex integration, repeatedly failed to add an account +pool, then could not call any model from Codex. After restarting, Codex would no longer sign in at +all: the client showed only "Unable to load sign-in requirements" and a Retry button. The reporter's +own analysis is that every Codex request was being routed to the local proxy on port 10100 and hung +there. + +## Why this outranks a model-routing bug + +The damaging part is not the failed inference. It is that the user is locked out of Codex sign-in +while the integration stays applied. A reboot does not clear an applied integration, so the lockout +survives it, and the failure surface offers no recovery the user can act on — the only visible +control is Retry against the endpoint that is failing. A user in that state cannot reach the +product that would let them undo the change. + +## What the lane must establish from source, not assume + +1. What the Codex integration apply path actually writes, and whether its scope covers only + inference or also the authentication and sign-in bootstrap. +2. What happens to those requests when the proxy is absent or hung: a bounded timeout, a fail-open + path, or an indefinite wait. +3. Whether Windows can reach a state where the integration stays applied while the service is not + running, including after a reboot. +4. Whether a recovery path exists that does not require the proxy to be running, and whether a user + in the failure state can discover it. +5. The account-pool add failure that triggered the sequence, recorded precisely rather than assumed + to share a cause. +6. Whether anything already on `dev` since 2.59.0 changes this path. + +## Fix standard + +Having the integration applied must not be able to lock a user out of Codex sign-in. Either the +authentication and sign-in bootstrap stays off the proxy path, or an unavailable proxy produces a +detectable failure and a discoverable recovery. Whichever holds, it is fixed by a regression that +simulates the dead-proxy state. + +## Execution constraint specific to this lane + +The incident is a configuration change that locked a user out. The lane therefore may not run the +proxy, start or restart the service, or modify any credential or configuration file on the working +machine; doing so would reproduce the damage locally. Verification is static source review plus +exact-head hosted CI. From 8e1fdea1c02ac1754f56b773d0c84c8470953301 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:53:02 +0900 Subject: [PATCH 06/87] fix(security): request-scoped transport pinning and managed-write symlink refusal (#5264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(transport): decide DNS pinning by whether the proxy applies to the request Carries the proxy-applies decision onto current dev and tightens the cases exact-head review found uncovered. The DNS-pinned provider transport used to leave pinning whenever ANY proxy variable was present (outboundProxyConfigured). Presence is not application: with only a scheme-mismatched variable set, an https: request still downgraded to the unpinned fetch even though Bun fetch would never use that proxy for it, and a local DNS failure silently degraded to the same unpinned fetch. The benchmark/fake-IP admission, the transport downgrade, the DNS-failure degradation and the private-network NO_PROXY demand now key on one snapshot: whether a proxy actually applies to this request (a usable, scheme-matched proxy variable that NO_PROXY does not exempt). effectiveProxyFor models that decision. Two corrections to the carried model: a non-SOCKS ALL_PROXY counts for plain http: targets on every CI platform, not just POSIX — the provider-outbound e2e drives that exact request through the proxy on Linux, macOS and Windows — and a present-but-unusable scheme-matched variable fails closed instead of falling through to ALL_PROXY, because no usable proxy is guaranteed either way and keeping the pinned transport is the safe direction. The Mihomo IPv6 fake-IP gate deliberately does not move to the new snapshot. Its documented condition is stricter — a scheme-matched variable or a SOCKS5 ALL_PROXY, with a non-SOCKS ALL_PROXY never counting — and admission pins the fetch to that value explicitly, so it now reads schemeMatchedProxyFor. That keeps every documented and tested #3462 behaviour byte-identical, including a SOCKS URL written into a scheme variable remaining a valid explicit binding. Regressions pin a scheme-mismatched variable keeping the pinned transport with benchmark answers rejected, a NO_PROXY match keeping it, a mismatched variable not demanding NO_PROXY for private providers, a DNS failure with only a mismatched variable surfacing instead of degrading, and the degradation surviving for the proxy that genuinely applies. Every caller of providerOutboundGet/Post — provider discovery, the model-catalog gather, quota probes, ollama show and the management model-refresh routes — shares this single decision function. The main inference dispatch and OAuth token exchange do not use the DNS-pinned transport today and are unchanged. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * fix(integrations): reject symlinked managed write targets Carries the managed-target symlink refusal onto current dev, without the src/config.ts re-export — that file sits exactly at its file-size ratchet cap, and the only consumer imports the leaf directly. A managed client configuration lives in a directory another process can write, and the writer used to resolve a symlink at the final path component both when inspecting the target and when committing the atomic replacement. A symlink swapped in between read and write could redirect the write — and the ownership journal's confidence — onto a file the integration does not manage. Three layers now refuse that. loadTarget probes the named directory entry without following it whenever the IO exposes a no-follow probe, so apply, refresh, disable and restore all classify a symlinked target as unsafe before any write is planned; the Aside profile guard forwards that probe so the per-profile path keeps the same boundary. fileIO.writeText rejects a non-regular entry up front. And the atomic commit replaces the named directory entry itself: a new atomicWriteFileNoFollow resolves only the parent (an OS alias above the configured root stays legitimate) and re-validates the target inside the write immediately before the rename, so a link exchanged after validation is refused rather than followed. A swap past the last check can only replace the named entry, never redirect through it. Regressions pin an omo catalog symlink refused at rest with its target byte-identical, a symlink swapped in during apply's snapshot window refused with no ownership recorded, a Cline pair member exchanged for a symlink at the write boundary unable to redirect the replacement, and disable and restore each refusing a symlinked target while leaving the linked file alone. Refresh shares the apply observation and write path, so it inherits the same refusals. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * docs(devlog): record lane B transport and write-safety progress * docs(devlog): link lane B progress to its pull request --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../030_lane_b_transport_write_safety.md | 66 ++++++ src/clients/aside-profiles.ts | 4 + src/config/atomic-write.ts | 32 ++- src/integrations/config-io.ts | 54 ++++- src/lib/provider-outbound.ts | 32 ++- src/lib/proxy-env.ts | 60 +++++- structure/clients/integrations.md | 5 + structure/config.md | 3 +- structure/runtime.md | 2 + tests/clients/cline-writer.test.ts | 26 ++- tests/clients/integrations-writer.test.ts | 86 +++++++- tests/providers/provider-outbound.test.ts | 188 +++++++++++++++++- 12 files changed, 527 insertions(+), 31 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/030_lane_b_transport_write_safety.md diff --git a/devlog/_plan/260920_meaning_preservation_batch/030_lane_b_transport_write_safety.md b/devlog/_plan/260920_meaning_preservation_batch/030_lane_b_transport_write_safety.md new file mode 100644 index 00000000000..c992b81e129 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/030_lane_b_transport_write_safety.md @@ -0,0 +1,66 @@ +# Lane B — request-scoped transport and managed-write safety + +Status: PR #5264 open against `dev` at exact head `235525b52a`, hosted CI +in flight. One branch, two ordered commits plus this progress record, per the +batch topology. + +## Scope + +- #5087 — DNS pinning and transport selection now follow whether a proxy + actually applies to the request, not whether one is configured. +- #5241 — a managed configuration write never follows a terminal symlink to + another file, including apply/refresh/disable/restore and their races. + +Both items carry the existing contributor pull requests by luvs01 with a +`Co-authored-by` trailer on each branch commit. The original pull requests +stay open for the coordinator. + +## Branch + +`codex/260920-lane-b-transport-write-safety`. Commits in order: + +1. `fix(transport): decide DNS pinning by whether the proxy applies to the request` +2. `fix(integrations): reject symlinked managed write targets` + +## Review findings on current dev (beyond the carried diffs) + +- #5087's carried model counted a non-SOCKS `ALL_PROXY` for `http:` targets on + POSIX only. The repository's own provider-outbound e2e drives that exact + request through the proxy and runs green on the Windows shard too, so the + platform gate was dropped: `ALL_PROXY` counts for `http:` on every CI + platform. A present-but-unusable scheme-matched variable now fails closed + instead of falling through to `ALL_PROXY`. +- The Mihomo IPv6 fake-IP gate keeps its stricter documented condition + (scheme-matched variable or SOCKS5 `ALL_PROXY`, non-SOCKS `ALL_PROXY` + never counts) via a dedicated `schemeMatchedProxyFor`, so the documented + and tested #3462 behaviour is byte-identical. Moving it to the new snapshot + would have contradicted the provider docs in every shipped locale. +- The carried #5087 diff had no regression for the DNS-failure degradation + branch. Added both directions: a mismatched proxy surfaces the DNS error + instead of degrading to an unpinned fetch; a scheme-matched proxy keeps the + degradation. +- #5241's carried diff re-exported the new primitive from `src/config.ts`, + which sits exactly at its file-size ratchet cap. The re-export was dropped; + the only consumer imports the leaf directly. +- #5241's carried tests covered apply (at rest and swap-during-write) and the + Cline pair boundary. Added disable and restore refusing a symlinked target + with the linked file byte-identical. Refresh shares the apply observation + and write path, so it inherits the same refusals. + +## Decision-function reach (#5087) + +Every `providerOutboundGet/Post` caller — provider discovery, the +model-catalog gather modules, quota probes, ollama show, and the management +model-refresh routes — funnels through the single changed decision. The main +inference dispatch (`providerFetch`) and OAuth token exchange +(`src/oauth/*`, bare global fetch) do not use the DNS-pinned transport today +and are unchanged. + +## Verification + +- Local suites, focused tests, typecheck, builds and live runs: NOT RUN (lane + rule). Verification is static source review plus exact-head hosted CI. +- Union-defect sweep: no capped file touched (`src/config.ts` left + byte-identical), no new test files (layout inventories unchanged), nothing + exhaustive over a union restated (locale catalogs, rosters and generated + counts untouched). diff --git a/src/clients/aside-profiles.ts b/src/clients/aside-profiles.ts index 770857611af..612e281ff0a 100644 --- a/src/clients/aside-profiles.ts +++ b/src/clients/aside-profiles.ts @@ -201,6 +201,7 @@ export function guardAsideProfileIO(profile: AsideProfile, io: IntegrationIO, pr const selected = { ...profile }; const registered = registeredProfiles(selected, profiles).map(peer => ({ ...peer })); const captured = boundary(selected, registered, false); + const lstatProbe = io.lstatKind; function check(path: string, directory: boolean, mutation: boolean): void { if (path !== (directory ? selected.detectDir : selected.configPath)) { refuse("IO attempted to access a different account path."); @@ -214,6 +215,9 @@ export function guardAsideProfileIO(profile: AsideProfile, io: IntegrationIO, pr return { readText: path => { check(path, false, false); return io.readText(path); }, statKind: path => { check(path, path === selected.detectDir, false); return io.statKind(path); }, + ...(lstatProbe + ? { lstatKind: (path: string) => { check(path, path === selected.detectDir, false); return lstatProbe(path); } } + : {}), writeText: (path, text) => { check(path, false, true); io.writeText(path, text); }, removeFile: path => { check(path, false, true); io.removeFile(path); }, mkdirp: path => { check(path, true, true); io.mkdirp(path); }, diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 4ad69b1bb9d..27f4471d402 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -10,7 +10,7 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { dirname } from "node:path"; +import { basename, dirname, join } from "node:path"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { @@ -215,14 +215,14 @@ async function writePrivateTempFileAsync( carryHardenAcrossContentWrite(path); } -export function atomicWriteFile( +function atomicWriteFileToTarget( path: string, content: string, + target: string, io?: AtomicWriteIO, hooks: AtomicWriteHooks = {}, ): void { recordOwnedConfigPath(getConfigDir(), path); - const target = resolveWriteTarget(path); assertResolvedTargetAllowed(path, target); const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; let hardened = false; @@ -287,6 +287,32 @@ export function atomicWriteFile( } } +export function atomicWriteFile( + path: string, + content: string, + io?: AtomicWriteIO, + hooks: AtomicWriteHooks = {}, +): void { + atomicWriteFileToTarget(path, content, resolveWriteTarget(path), io, hooks); +} + +/** + * Atomically replace the named directory entry without resolving a symlink at + * that entry. This is for files in directories writable by another process: + * a raced symlink is replaced, never followed to a more privileged target. + */ +export function atomicWriteFileNoFollow( + path: string, + content: string, + io?: AtomicWriteIO, + hooks: AtomicWriteHooks = {}, +): void { + // Only the final entry is no-follow: the parent still resolves, because an + // OS alias above the configured root (a home junction, /tmp) is legitimate + // and Windows cannot exclusive-create a temp through a junction. + atomicWriteFileToTarget(path, content, join(resolveWriteTarget(dirname(path)), basename(path)), io, hooks); +} + export interface AtomicWriteAsyncIO { write: (path: string, content: string) => void | Promise; harden: (path: string) => void | Promise; diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts index 8a1e1480ec8..df5337a121e 100644 --- a/src/integrations/config-io.ts +++ b/src/integrations/config-io.ts @@ -10,7 +10,7 @@ import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs"; import type { ConfigFormat } from "../clients/config-export"; import { MAX_JSON_NESTING } from "./serialize"; -import { atomicWriteFile } from "../config"; +import { atomicWriteFileNoFollow, isMissingPathError } from "../config/atomic-write"; import type { JournalEntry } from "./journal"; import type { OwnershipRecord } from "./ownership"; import type { IntegrationClientId } from "./registry"; @@ -192,6 +192,15 @@ export type ReadResult = export type StatKind = "file" | "dir" | "other" | "missing" | "failed"; +function lstatKind(path: string): StatKind { + try { + const stats = lstatSync(path); + return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other"; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed"; + } +} + export interface IntegrationIO { /** * ONLY a missing file yields `missing`. Every other failure (EACCES, EPERM, @@ -234,6 +243,18 @@ export type TargetState = * an unreadable config gets clobbered. */ export function loadTarget(io: IntegrationIO, configPath: string): TargetState { + // Managed client paths are a lower-trust boundary. Never inspect through a + // final symlink that can be retargeted between this read and the eventual + // write: when a no-follow probe exists, the named directory entry itself must + // be a regular file or absent. `missing` stays legal so a virtual pair probe + // (such as Cline's pair-aware statKind) can still report one absent member. + const named = io.lstatKind?.(configPath); + // A failed probe is uncertainty, not evidence about the entry's shape: it + // keeps the read-failed classification the follow-probe would have produced. + if (named === "failed") return { ok: false, why: "read-failed" }; + if (named !== undefined && named !== "file" && named !== "missing") { + return { ok: false, why: "not-regular-file" }; + } const kind = io.statKind(configPath); if (kind === "missing") return { ok: true, before: null }; if (kind === "failed") return { ok: false, why: "read-failed" }; @@ -252,14 +273,7 @@ export function loadTarget(io: IntegrationIO, configPath: string): TargetState { */ export function fileIO(): Omit { return { - lstatKind: path => { - try { - const stats = lstatSync(path); - return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other"; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed"; - } - }, + lstatKind, readText: path => { try { return { kind: "text", text: readFileSync(path, "utf8") }; @@ -277,8 +291,28 @@ export function fileIO(): Omit { + const kind = lstatKind(path); + if (kind !== "file" && kind !== "missing") { + throw new Error(`refusing unsafe integration write target: ${path}`); + } assertIntegrationWriteOwnership(path); - atomicWriteFile(path, text); + atomicWriteFileNoFollow(path, text, undefined, { + // The pre-check above rejects a symlink already in place; this one runs + // inside the atomic write immediately before the rename, so a link + // exchanged after validation is refused rather than followed. Even a + // swap past this point can only replace the named entry, never redirect + // the write through it. + validateBeforeRename: target => { + try { + if (lstatSync(target).isSymbolicLink()) { + throw new Error(`refusing to replace symbolic-link integration target: ${target}`); + } + } catch (error) { + if (isMissingPathError(error)) return; + throw error; + } + }, + }); }, removeFile: path => rmSync(path, { force: true }), mkdirp: path => mkdirSync(path, { recursive: true, mode: 0o700 }), diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 799c8d69fa6..d87893cfa4f 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -7,7 +7,7 @@ import { resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; -import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env"; +import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, schemeMatchedProxyFor } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; @@ -186,13 +186,23 @@ async function providerOutboundRequest( return provider.fetch(url, { ...init, method, redirect: "manual" }); } const parsed = postUrl ?? new URL(url); - const proxyConfigured = outboundProxyConfigured(); - // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport - // below reason about the same value. `null` here means "no proxy fetch would actually use", - // even if some other proxy variable is set. + // Snapshot the proxy fetch would actually use once, before the DNS await, so admission + // and transport below reason about the same value. `null` here means "no proxy fetch + // would actually use", even if some other proxy variable is set. const effectiveProxy = effectiveProxyFor(parsed); + // The request leaves the DNS-pinned transport only when a proxy will actually carry it: + // a proxy variable fetch would use for this URL that NO_PROXY does not exempt. + // A scheme-mismatched or unusable variable, a NO_PROXY match, or an ALL_PROXY + // this target's scheme cannot use must not downgrade pinning or admit + // proxy-only DNS answers. + const proxyApplies = effectiveProxy !== null && !noProxyMatches(parsed); const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false); - const allowMihomoIpv6FakeIp = (effectiveProxy !== null && !noProxyMatches(parsed)) + // The IPv6 fake-IP gate keeps its stricter documented condition — a + // scheme-matched variable or a SOCKS5 ALL_PROXY, never a non-SOCKS + // ALL_PROXY — even when proxyApplies admits one for the transport + // decision, because admission binds the fetch to this value explicitly. + const bindingProxy = schemeMatchedProxyFor(parsed); + const allowMihomoIpv6FakeIp = (bindingProxy !== null && !noProxyMatches(parsed)) || transparentFakeIpException(url, parsed, isCanonicalUrl, name); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; @@ -216,7 +226,7 @@ async function providerOutboundRequest( // proof is on the final request URL — not the provider name — because an // OAuth/forward name matches any baseUrl by design while the bearer is // pinned to the registry destination independently. - allowBenchmarkAddresses: (proxyConfigured && !noProxyMatches(parsed)) + allowBenchmarkAddresses: proxyApplies || transparentFakeIpException(url, parsed, isCanonicalUrl, name), // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound // to a scheme-matched proxy (#3462) or under the TUN transparency exception for a @@ -229,21 +239,21 @@ async function providerOutboundRequest( if (!dnsResolutionFailed) { throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked"); } - if (!proxyConfigured) throw error; + if (!proxyApplies) throw error; warnProxyBoundaryOnce(); warnProxyDnsDegradationOnce(); return configuredOutboundFetch(url, { ...init, method, redirect: "manual" }); } // A canonical TUN exception with no scheme-matched proxy must retain the // validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present. - if (proxyConfigured && !resolved.privateNetwork && (effectiveProxy !== null || !allowMihomoIpv6FakeIp)) { + if (proxyApplies && !resolved.privateNetwork) { warnProxyBoundaryOnce(); // When the Mihomo exception could have admitted an answer, pin the transport to the // proxy the admission assumed instead of letting fetch re-infer it from the environment. - const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined; + const proxy = (allowMihomoIpv6FakeIp && bindingProxy) ? bindingProxy : undefined; return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); } - if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) { + if (proxyApplies && resolved.privateNetwork) { const hostname = normalizeProxyHostname(parsed.hostname); throw new Error( `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`, diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index aebf62730bc..3c3e80052dd 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -86,12 +86,30 @@ export function outboundProxyConfigured( return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); } +/** + * The value when `raw` is a proxy URL Bun fetch can actually use, else null. + * Bun rejects unparseable values and non-http(s) schemes (UnsupportedProxyProtocol), + * so admitting them as "the proxy that applies" would only downgrade DNS pinning. + */ +function usableHttpProxyUrl(raw: string | undefined): string | null { + if (!raw) return null; + try { + const scheme = new URL(raw).protocol; + return scheme === "http:" || scheme === "https:" ? raw : null; + } catch { + return null; + } +} + /** * The proxy URL selected by configured outbound fetch for `url`, or null when none applies. * * Bun selects by scheme: `HTTPS_PROXY` for `https:` targets, `HTTP_PROXY` for `http:`. - * A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first; other ALL_PROXY - * schemes remain excluded because the native HTTP fetch does not honor them. + * A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first. A non-SOCKS + * `ALL_PROXY` is still honoured by the native fetch for plain `http:` targets on + * every platform the CI matrix covers — the provider-outbound e2e drives exactly that + * request through the proxy on Linux, macOS and Windows. For `https:` targets the + * SOCKS wrapper remains the only `ALL_PROXY` route this module counts. * Presence of *some* proxy variable (`outboundProxyConfigured`) is not that guarantee. */ export function effectiveProxyFor( @@ -107,8 +125,44 @@ export function effectiveProxyFor( // The installed SOCKS wrapper takes this route before Bun sees scheme proxies. const socksProxy = socks5ProxyFromEnv(env); if (socksProxy) return socksProxy; + const schemeValue = env[key]?.trim() || env[key.toLowerCase()]?.trim(); + if (schemeValue) { + // A present but unusable scheme-matched variable fails closed: it is not a + // proxy Bun fetch can use, and it must not fall through to ALL_PROXY either. + // If Bun would have used ALL_PROXY here, keeping the DNS-pinned transport is + // the safe direction; if it would not, this is exactly right. + return usableHttpProxyUrl(schemeValue); + } + if (url.protocol !== "http:") return null; + return usableHttpProxyUrl(env.ALL_PROXY?.trim() || env.all_proxy?.trim()); +} + +/** + * The proxy a request can be explicitly bound to for fake-IP admission, or + * null: a scheme-matched variable or a SOCKS5 `ALL_PROXY`. This is the + * stricter documented gate for Mihomo IPv6 fake-IP answers — a non-SOCKS + * `ALL_PROXY` does not count here even when `effectiveProxyFor` reports it, + * because admission pins the transport to the returned value and the gate's + * contract is stated in those terms. The scheme-matched value counts only as + * a usable binding — a SOCKS or http(s) URL; anything else would admit a + * fake-IP answer nothing can resolve. + */ +export function schemeMatchedProxyFor( + url: URL, + env: ProxyEnvMap = process.env, +): string | null { + const key: ProxyEnvKey | null = url.protocol === "https:" + ? "HTTPS_PROXY" + : url.protocol === "http:" + ? "HTTP_PROXY" + : null; + if (!key) return null; + const socksProxy = socks5ProxyFromEnv(env); + if (socksProxy) return socksProxy; const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); - return value ? value : null; + if (!value) return null; + if (isSocks5ProxyUrl(value)) return value; + return usableHttpProxyUrl(value); } export function isSocks5ProxyUrl(proxy: string): boolean { diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 402a60e07f0..5e674c73ed0 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -6,6 +6,8 @@ The client-integration subsystem writes one generated OpenCodex provider contrib third-party client's existing config without taking ownership of the rest of that file. Its core promise is reversibility: apply snapshots first, writes atomically, records exactly what it owns, and refuses refresh, disable, or restore when the current file cannot be classified safely. +Managed client targets are inspected without following a final symbolic link, and their atomic +replacement addresses the named directory entry rather than resolving that link again at commit. Shared response support has a separate [bounded ingestion contract](../transports/inventory.md#bounded-response-ingestion-and-orcarouter-login): raw-byte callers own their byte and deadline budgets and inherit best-effort cancellation. @@ -280,3 +282,6 @@ existing explicit confirmation. The journal endpoint evaluates Undo against the Recovery reads commit history and ownership through strict store methods. Unreadable or malformed metadata is uncertainty, never evidence that a transaction did not commit. Pending records validate complete ownership, exact Cline paths and result fingerprints before either native file is replaced. +Native pair writes replace the named directory entries without following final symlinks. A symlink +present at validation is refused, and one exchanged into place during a mutation is refused rather +than redirecting OpenCodex's write outside Cline's settings directory. diff --git a/structure/config.md b/structure/config.md index 5d68a5ad766..47e0fb54033 100644 --- a/structure/config.md +++ b/structure/config.md @@ -64,7 +64,8 @@ owner-only state and is validated before a record is returned. `src/config.ts` r symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared -process-wide temp sequence, symlink target resolution, real-home test guard, owner manifest, +process-wide temp sequence, symlink target resolution, no-follow directory-entry replacement for +externally writable integration directories, real-home test guard, owner manifest, Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. A caller must not replace it with a local temp-and-rename shortcut. diff --git a/structure/runtime.md b/structure/runtime.md index 130ab5d4bfe..5c19599c4f5 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -399,6 +399,8 @@ privately to final dispatch; preliminary route selection does not inject Go-only Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. Cline CLI joins the existing export/client integration registries. Explicit CLI sync and POST /api/sync refresh its owned pair; unattended catalog refresh excludes it. See [Cline paired files](clients/integrations.md#cline-paired-files). +Its paired-file writer uses the config atomic-write primitive that replaces the named entry without +following a final symlink, so an exchange during a mutation cannot redirect the write. `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). diff --git a/tests/clients/cline-writer.test.ts b/tests/clients/cline-writer.test.ts index 4247f0abde8..d9275ed9b96 100644 --- a/tests/clients/cline-writer.test.ts +++ b/tests/clients/cline-writer.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { clineConfigPath } from "../../src/clients/config-export"; @@ -140,6 +140,30 @@ describe("Cline journaled pair", () => { expect(readFileSync(catalog, "utf8")).toBe(originalCatalog); }); + test.skipIf(process.platform === "win32")("a member exchanged for a symlink at the write boundary cannot redirect replacement", () => { + seed(); + const victim = join(root, "victim.json"); + const displaced = `${settings}.attacker-saved`; + writeFileSync(victim, '{"sentinel":true}'); + const io = input.store!.io(); + let exchanged = false; + const result = applyIntegration({ ...input, io: { + ...io, + writeText: (path, text) => { + if (path === settings && !exchanged) { + exchanged = true; + renameSync(settings, displaced); + symlinkSync(victim, settings); + } + io.writeText(path, text); + }, + } }); + expect(result.ok).toBe(false); + expect(readFileSync(victim, "utf8")).toBe('{"sentinel":true}'); + expect(lstatSync(settings).isSymbolicLink()).toBe(true); + expect(readFileSync(displaced, "utf8")).toBe(originalSettings); + }); + test("occupied custom provider requires explicit overwrite and remains reversible", () => { seed(); const doc = settingsDoc(); diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index 16d118cd3fd..53d78f9415c 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { buildClientContribution, type ExportModel } from "../../src/clients/config-export"; @@ -93,6 +95,14 @@ function installOmp(): string { return configPath; } +function installOmo(): string { + const spec = INTEGRATION_CLIENTS.omo; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; +} + function installDsh(): string { const spec = INTEGRATION_CLIENTS.dsh; mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); @@ -149,6 +159,44 @@ function reverseJsonObjectKeys(value: unknown): unknown { } describe("apply", () => { + test.skipIf(process.platform === "win32")("refuses an omo catalog symlink without changing its target", () => { + const configPath = installOmo(); + const victim = join(dirname(home), "victim.json"); + const original = '{"security":{"mode":"strict"}}\n'; + writeFileSync(victim, original); + symlinkSync(victim, configPath); + + expect(readIntegrationState(input({ clientId: "omo" })).state).toBe("unsafe"); + const result = applyIntegration(input({ clientId: "omo" })); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(victim, "utf8")).toBe(original); + expect(store.listOperations("omo")).toHaveLength(0); + }); + + test.skipIf(process.platform === "win32")("an omo symlink swap before commit cannot replace its target", () => { + const configPath = installOmo(); + const checked = '{"notes":"client-owned"}\n'; + writeFileSync(configPath, checked); + const victim = join(dirname(home), "victim.json"); + const original = '{"security":{"mode":"strict"}}\n'; + writeFileSync(victim, original); + const captureSnapshot = store.captureSnapshot.bind(store); + store.captureSnapshot = (clientId, opId, before) => { + const snapshot = captureSnapshot(clientId, opId, before); + unlinkSync(configPath); + symlinkSync(victim, configPath); + return snapshot; + }; + + const result = applyIntegration(input({ clientId: "omo" })); + + expect(result.ok).toBe(false); + expect(readFileSync(victim, "utf8")).toBe(original); + expect(store.readRecords().omo).toBeUndefined(); + }); + test("refuses Kimi TOML date rewrites without changing the file or ownership store", () => { const spec = INTEGRATION_CLIENTS.kimi; mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); @@ -789,6 +837,24 @@ describe("apply", () => { }); describe("disable", () => { + test.skipIf(process.platform === "win32")("refuses a symlinked managed target and leaves its target alone", () => { + const configPath = installOmo(); + const applied = applyIntegration(input({ clientId: "omo" })); + expect(applied.ok).toBe(true); + const victim = join(dirname(home), "victim.json"); + const original = '{"security":{"mode":"strict"}}\n'; + writeFileSync(victim, original); + unlinkSync(configPath); + symlinkSync(victim, configPath); + + const result = disableIntegration(input({ clientId: "omo" })); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(victim, "utf8")).toBe(original); + expect(store.readRecords().omo).toBeDefined(); + }); + test("removes only our block and leaves the rest byte-identical", () => { const configPath = installHermes(); const original = "providers:\n other:\n api: http://elsewhere\nunknown_top: keep-me\n"; @@ -1078,6 +1144,24 @@ describe("Hermes source preservation", () => { }); describe("restore", () => { + test.skipIf(process.platform === "win32")("refuses a symlinked managed target and leaves its target alone", () => { + const configPath = installOmo(); + const applied = applyIntegration(input({ clientId: "omo" })); + expect(applied.ok).toBe(true); + const opId = store.listOperations("omo")[0]!.opId; + const victim = join(dirname(home), "victim.json"); + const original = '{"security":{"mode":"strict"}}\n'; + writeFileSync(victim, original); + unlinkSync(configPath); + symlinkSync(victim, configPath); + + const result = restoreIntegration({ ...input({ clientId: "omo" }), opId }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(victim, "utf8")).toBe(original); + }); + test("undoes an apply back to the exact prior bytes", () => { const configPath = installHermes(); const original = "providers:\n other:\n api: http://elsewhere\n"; diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index c0d5f59a2a9..a56ca4aa105 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { DestinationDnsResolutionError } from "../../src/lib/destination-policy"; import type { ProviderOutboundDependencies } from "../../src/lib/provider-outbound"; import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -234,6 +235,153 @@ describe("provider outbound GET transport", () => { } }); + test("scheme-mismatched proxy variables keep the DNS-pinned transport", async () => { + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response("unexpected", { status: 500 })) as typeof fetch; + globalThis.fetch = fetchMock; + try { + for (const { url, proxyKey } of [ + { url: "http://provider.example/v1/models", proxyKey: "HTTPS_PROXY" }, + { url: "https://provider.example/v1/models", proxyKey: "HTTP_PROXY" }, + ] as const) { + for (const key of proxyKeys) delete process.env[key]; + process.env[proxyKey] = "http://127.0.0.1:9"; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const resolveOptions: { allowBenchmarkAddresses?: boolean }[] = []; + const { dependencies, captured } = directDependencies(new Response(null, { status: 204 })); + dependencies.resolveAddresses = mock(async (_url: string, options?: { allowBenchmarkAddresses?: boolean }) => { + resolveOptions.push({ allowBenchmarkAddresses: options?.allowBenchmarkAddresses }); + return { + hostname: "provider.example", + addresses: [{ address: "93.184.216.34", family: 4 }], + privateNetwork: false, + }; + }) as ProviderOutboundDependencies["resolveAddresses"]; + + const response = await providerOutboundGet( + "custom", + { baseUrl: new URL(url).origin + "/v1" }, + url, + {}, + dependencies, + ); + + expect(response.status).toBe(204); + expect(captured.address).toBe("93.184.216.34"); + expect(resolveOptions).toEqual([{ allowBenchmarkAddresses: false }]); + } + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("a NO_PROXY match keeps the request on the DNS-pinned transport", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = "http://127.0.0.1:9"; + process.env.NO_PROXY = "provider.example"; + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response("unexpected", { status: 500 })) as typeof fetch; + globalThis.fetch = fetchMock; + try { + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = directDependencies(new Response(null, { status: 204 })); + + const response = await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + {}, + dependencies, + ); + + expect(response.status).toBe(204); + expect(captured.address).toBe("93.184.216.34"); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("a scheme-mismatched proxy variable does not demand NO_PROXY for private providers", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTP_PROXY = "http://127.0.0.1:9"; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = directDependencies(new Response(null, { status: 200 }), { + privateNetwork: true, + address: "192.168.1.50", + }); + + const response = await providerOutboundGet( + "ollama-lan", + { baseUrl: "https://ollama.lan:11434/v1", allowPrivateNetwork: true }, + "https://ollama.lan:11434/v1/models", + {}, + dependencies, + ); + + expect(response.status).toBe(200); + expect(captured.address).toBe("192.168.1.50"); + }); + + test("DNS failure with only a scheme-mismatched proxy rethrows instead of degrading to an unpinned fetch", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTP_PROXY = "http://127.0.0.1:9"; + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response("unexpected", { status: 500 })) as typeof fetch; + globalThis.fetch = fetchMock; + try { + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies } = directDependencies(new Response(null, { status: 204 })); + dependencies.resolveAddresses = mock(async () => { + throw new DestinationDnsResolutionError("getaddrinfo ENOTFOUND provider.example"); + }) as ProviderOutboundDependencies["resolveAddresses"]; + + // A proxy variable fetch would never use for this https: target must not + // license the unpinned degradation path: the DNS failure surfaces as-is. + await expect(providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + {}, + dependencies, + )).rejects.toBeInstanceOf(DestinationDnsResolutionError); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("DNS failure behind a scheme-matched proxy still degrades to the proxy fetch", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = "http://127.0.0.1:9"; + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response('{"data":[]}', { status: 200 })) as typeof fetch; + globalThis.fetch = fetchMock; + try { + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies } = directDependencies(new Response(null, { status: 204 })); + dependencies.resolveAddresses = mock(async () => { + throw new DestinationDnsResolutionError("getaddrinfo ENOTFOUND provider.example"); + }) as ProviderOutboundDependencies["resolveAddresses"]; + + // The proxy the request will actually use may resolve names the local + // resolver cannot, so the degradation stays for the route that applies. + const response = await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + {}, + dependencies, + ); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("built-in ollama admits loopback discovery without an explicit allowPrivateNetwork flag (#758)", async () => { for (const key of proxyKeys) delete process.env[key]; const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); @@ -571,7 +719,7 @@ describe("#3462 Mihomo IPv6 fake-IP admission is gated on the scheme-matched pro }); describe("effectiveProxyFor picks the variable Bun fetch actually honours", () => { - test("scheme-matched selection; HTTP ALL_PROXY is never consulted", async () => { + test("scheme-matched selection; HTTP ALL_PROXY only counts for http: targets", async () => { const { effectiveProxyFor } = await import("../../src/lib/proxy-env"); const https = new URL("https://opencode.ai/zen/v1/models"); const http = new URL("http://ollama.lan:11434/v1/models"); @@ -582,9 +730,47 @@ describe("effectiveProxyFor picks the variable Bun fetch actually honours", () = expect(effectiveProxyFor(https, { ALL_PROXY: "socks5://127.0.0.1:1080" })).toBe("socks5://127.0.0.1:1080"); expect(effectiveProxyFor(http, { HTTP_PROXY: "http://p:5" })).toBe("http://p:5"); expect(effectiveProxyFor(http, { HTTPS_PROXY: "http://p:6" })).toBeNull(); + // Bun's native fetch honours a non-SOCKS ALL_PROXY for plain http: targets on + // every platform the CI matrix covers (the provider-outbound e2e proves the + // request reaches the proxy); https: targets only ever use the socks5 wrapper. + expect(effectiveProxyFor(http, { ALL_PROXY: "http://p:7" })).toBe("http://p:7"); + expect(effectiveProxyFor(http, { all_proxy: "http://p:13" })).toBe("http://p:13"); + expect(effectiveProxyFor(http, { ALL_PROXY: "ftp://p:8" })).toBeNull(); + expect(effectiveProxyFor(http, { ALL_PROXY: "http://" })).toBeNull(); + // A malformed or non-http(s) scheme-matched variable is not a proxy Bun fetch + // can use either: it must not count as "the proxy that applies". + expect(effectiveProxyFor(http, { HTTP_PROXY: "http://" })).toBeNull(); + expect(effectiveProxyFor(http, { HTTP_PROXY: "not a url" })).toBeNull(); + expect(effectiveProxyFor(https, { HTTPS_PROXY: "http://" })).toBeNull(); + expect(effectiveProxyFor(https, { HTTPS_PROXY: "socks5://p:9" })).toBeNull(); expect(effectiveProxyFor(https, { HTTPS_PROXY: " " })).toBeNull(); + // A present-but-unusable scheme-matched variable fails closed rather than + // falling through to ALL_PROXY: no usable proxy is guaranteed either way, + // so the DNS-pinned transport must stay. + expect(effectiveProxyFor(http, { HTTP_PROXY: "not a url", ALL_PROXY: "http://p:10" })).toBeNull(); + expect(effectiveProxyFor(https, { HTTPS_PROXY: "ftp://p:11", ALL_PROXY: "http://p:12" })).toBeNull(); expect(effectiveProxyFor(new URL("ftp://x/"), { HTTPS_PROXY: "http://p:7", HTTP_PROXY: "http://p:7" })).toBeNull(); }); + + test("schemeMatchedProxyFor keeps the stricter fake-IP binding gate", async () => { + const { schemeMatchedProxyFor } = await import("../../src/lib/proxy-env"); + const https = new URL("https://opencode.ai/zen/v1/models"); + const http = new URL("http://ollama.lan:11434/v1/models"); + expect(schemeMatchedProxyFor(https, { HTTPS_PROXY: "http://p:1" })).toBe("http://p:1"); + expect(schemeMatchedProxyFor(http, { HTTP_PROXY: "http://p:2" })).toBe("http://p:2"); + // A SOCKS URL in a scheme-matched variable is a valid explicit binding. + expect(schemeMatchedProxyFor(https, { HTTPS_PROXY: "socks5://p:3" })).toBe("socks5://p:3"); + expect(schemeMatchedProxyFor(https, { ALL_PROXY: "socks5://p:4" })).toBe("socks5://p:4"); + // A non-SOCKS ALL_PROXY never counts for the binding gate, even for the + // http: targets effectiveProxyFor reports it for. + expect(schemeMatchedProxyFor(http, { ALL_PROXY: "http://p:5" })).toBeNull(); + expect(schemeMatchedProxyFor(https, { ALL_PROXY: "http://p:6" })).toBeNull(); + expect(schemeMatchedProxyFor(https, { HTTP_PROXY: "http://p:7" })).toBeNull(); + // An unusable scheme-matched value is not a binding either: admitting a + // fake-IP answer against it would pin-connect to an address nothing resolves. + expect(schemeMatchedProxyFor(https, { HTTPS_PROXY: "not a url" })).toBeNull(); + expect(schemeMatchedProxyFor(https, { HTTPS_PROXY: "ftp://p:8" })).toBeNull(); + }); }); describe("provider outbound default User-Agent", () => { From 447ac22ca65baabd573dbd429abe28ed9deff594 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:57:38 +0900 Subject: [PATCH 07/87] fix(adapters,server): output budgets, adapter queue memory, and per-key model policy (#5265) * fix(devin): send the configured output budget instead of the encoder default Codex never sends max_output_tokens, and the devin adapter only forwarded a caller-supplied value, so every devin turn was capped at the cloud-direct encoder's 8192 fallback however the provider was configured. A turn that legitimately needed more ended as an upstream incomplete/max_output_tokens the client then retried into the same deterministic wall. Resolve the cap the way the other adapters do, highest authority first: an explicit caller value forwarded unchanged, then the configured per-model cap, then the provider default, then the encoder fallback. The lookup is the same UID-aware hint resolution the input ceiling already uses, extracted so both sides read a per-model number identically. The output cap and the history ceiling stay separate. CompletionConfiguration #2 is the output cap and #3 is the context window, so the resolver reads neither contextWindow nor modelContextWindows -- collapsing them would ask Cognition to generate a whole context window of output. Also stop OAuth startup reconciliation from deleting an operator's output budget. No OAuth preset declares defaultMaxOutputTokens or modelMaxOutputTokens, so the delete-when-preset-undefined branch was the only branch either field ever took and a hand-edited value was wiped before the next startup finished. A preset that does declare one still refreshes the row. Closes #5190 * feat(coding-agent): derive the projected-history ceiling from the model context window The coding-agent CLIs replay the whole conversation each turn as one projected user message over stream-json stdin, and the projection had a flat 200k-character history ceiling: roughly 50k tokens of English or code, a fraction of what even a 128k-token model holds and far below the 1M-token families. Long sessions lost their earliest context at a bound unrelated to the model. Derive the ceiling from the declared model context window on the routed provider row -- modelContextWindows by model, then the provider-wide contextWindow -- at three characters per token, floored at the legacy 200k cap so small windows and missing metadata behave exactly as before, and capped by a 4M hard ceiling so runaway metadata cannot unbound stdin. The resolution lives once in the shared runCodingAgentTurn driver, so both CodeBuddy and Qoder turns get it and the family adapters are unchanged. This ceiling is a runaway-memory bound on replayed history, measured in characters. It is deliberately not the output budget: caller-side compaction remains the token authority, and nothing here decides how long a reply may run. Co-authored-by: mdwsk88 <924038395@qq.com> * fix(adapters): bound the event queue by retained payload, not by event count alone The adapter event queue capped how many events it buffered but never how much those events held. Coalescing merges adjacent deltas up to 64 KiB an item, so the old 1024-event cap admitted about 64 MiB of retained text before it said anything, and a single oversized event was unbounded on its own. Charge what the queue actually retains, against two separate budgets. The aggregate budget bounds everything held at one moment; the per-event budget bounds one event and applies however empty the queue is. They describe different failures -- a consumer that is not keeping up versus an event that is malformed -- so they report different terminal messages and an operator can tell which happened. The accounting is exact on every path. Each queued item records what it was charged, so a merge pays only for the text it appends, a dequeue gives back precisely what it took, a refused event is priced before anything is retained and never charged, and the terminal record that explains a refusal is admitted past the budget it reports but still charged and released. Draining therefore returns the counter to zero after a normal turn, after an overflow abort and after a consumer walks away mid-stream; retainedCodeUnits() exposes that so a regression can assert it rather than infer it from an abort that happened to fire. A long healthy stream is still not capped by its total length: every dequeue releases its charge, so only an undrained backlog accumulates. The aggregate default is sized for the other legitimate case -- a synchronous producer that fills the queue before its consumer is scheduled, as the image loop does with over a million one-character deltas -- which is roughly 1.2 MB of retained text and must not abort. Retention is measured by walking own enumerable properties rather than by naming each variant's string fields, because a hand-written per-variant table is exhaustive over the AdapterEvent union and would silently stop counting a member added on another branch. The walk carries depth and node ceilings so one push stays cheap against the open provider-shaped payloads two members carry. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * feat(server): scope an admission key to the models and providers it may reach A hub serving several clients with their own data-plane keys had no way to stop a mail or cron key spending a coding key's Grok or Claude quota. Hub-level model selection cannot express it: it hides a model from everyone or from no one. An admission key may now declare allowedProviders and allowedModels. Absent or empty means unrestricted, which is every existing key, so nothing changes until an operator sets one. Two rules decide what this is. A scope names destinations, not selectors: it is evaluated against the resolved provider and model a turn will actually bill, never against the string the client sent. Alias resolution, a policy or combo selection, a subagent fallback and a compaction override all rewrite that string, so a scope checked at the front door would authorize one destination and reach another. And a scope is never management authority -- it narrows which models an inference key may call and grants nothing else. Enforcement sits where each route becomes concrete. On the Responses path that is the single point every produced route passes through, which covers the direct name, an alias, a policy or combo child, a shadow-intercept target, both subagent-fallback re-routes and, through translation, the Chat and Messages surfaces. The native Chat lane and the compaction route send without re-entering that path, so each applies the same predicate itself. A refusal is 403 with a stable model_not_allowed_for_key type naming the caller's own selector; the resolved destination stays in the server log, because a key that may not reach a provider has no business learning that its alias points there. /v1/models filters by the same predicate, so what a key can see and what it can call cannot diverge. That filter is a convenience and not the boundary: hiding a row only stops a client that reads the catalog first. A malformed scope drops the key rather than degrading to undefined, unlike every other field on the record. Degrading a damaged permission field reads as "allowed everything", which is the one direction it must never fail. Management exposes the lists on GET /api/keys and accepts them on PATCH, where rename and scope are independent edits, and ocx access key get/set reads and writes them without printing or rotating the secret. Closes #5049 * docs(devlog): record lane E of the phase 2 consolidation batch * fix(server,adapters): close the virtual-model scope gap and harden queue retention Three findings from an adversarial review of this branch. The scope check ran before applyOpenAiVirtualModel, which rewrites route.modelId to the wire id that is actually billed. A key allowing only the public selector was therefore authorized on one model and sent on another. The settled route is now re-checked after normalization, so the id that is billed is the id that was authorized. The account-qualified branch of /v1/alpha/search resolves a model through the router and bills the account it names, so it applies the same rule. The endpoints that spend quota without routing a model -- images, audio, realtime, and the non-account-qualified search branch -- are recorded in the lane document as uncovered rather than left to read as covered. The queue's per-event budget comment claimed it bound any single event. It bounds a RETAINED one: an event handed straight to a waiting consumer is never held, so refusing it would abort a turn over memory this queue does not own. The comment now says what the code does. retainedEventCodeUnits also guards a non-object, so a malformed adapter emission becomes a terminal event rather than a TypeError thrown out of push with the queue half-updated. The scope regression reached the config schema through config/schema/leaf-validators directly, which enters that module cycle from the wrong end and threw a TDZ ReferenceError on CI. It now loads a hand-written config.json through src/config the way a startup does, which also proves the stronger property: a damaged permission field drops that key alone and its valid neighbour survives. --------- Co-authored-by: mdwsk88 <924038395@qq.com> Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../050_lane_e.md | 128 +++++++++++ scripts/test-layout/layout.json | 4 +- src/adapters/coding-agent/protocol.ts | 31 ++- src/adapters/coding-agent/turn.ts | 12 +- src/adapters/devin.ts | 95 ++++++-- src/adapters/run-turn-queue.ts | 207 +++++++++++++++--- src/cli/access.ts | 87 ++++++++ src/config/schema/leaf-validators.ts | 7 + src/oauth/index.ts | 25 ++- src/server/admission-model-scope.ts | 166 ++++++++++++++ src/server/chat-completions.ts | 15 ++ src/server/claude-messages.ts | 18 ++ src/server/index/serve-options.ts | 24 +- src/server/management/oauth-account-routes.ts | 43 +++- src/server/responses/compact.ts | 11 + src/server/responses/request-prepare.ts | 29 +++ src/server/search.ts | 12 + src/types/config.ts | 15 ++ tests/adapters/run-turn-queue.test.ts | 180 ++++++++++++++- tests/fixtures/test-layout-expected.json | 4 +- tests/oauth/oauth-provider-reconcile.test.ts | 37 ++++ tests/providers/codebuddy-adapter.test.ts | 31 +++ tests/providers/codebuddy-protocol.test.ts | 40 ++++ tests/providers/devin-output-budget.test.ts | 183 ++++++++++++++++ tests/server/api-key-model-scope.test.ts | 159 ++++++++++++++ 25 files changed, 1495 insertions(+), 68 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/050_lane_e.md create mode 100644 src/server/admission-model-scope.ts create mode 100644 tests/providers/devin-output-budget.test.ts create mode 100644 tests/server/api-key-model-scope.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/050_lane_e.md b/devlog/_plan/260920_meaning_preservation_batch/050_lane_e.md new file mode 100644 index 00000000000..154a0a639e9 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/050_lane_e.md @@ -0,0 +1,128 @@ +# Lane E — output budgets, queue memory, per-key policy + +Status: OPEN. Branch `codex/260920-lane-e-budgets-key-policy` against `dev`, one pull request. +Covers phase 2 bundles 10, 11 and 12 from [010_phase2.md](010_phase2.md). + +## What each bundle turned out to be + +### 10 — Devin output budget and the history ceiling + +Two defects, not one. The adapter forwarded only a caller-supplied +`max_output_tokens`, and Codex never sends one, so every `devin/*` turn was capped at +the cloud-direct encoder's 8192 fallback however the provider was configured. The +escape hatch was closed too: no OAuth preset declares `defaultMaxOutputTokens` or +`modelMaxOutputTokens`, so the delete-when-preset-undefined branch in +`applyOAuthPresetCatalog` was the only branch either field ever took and a +hand-edited value was gone before the next startup finished. Both had to move, or +wiring the adapter alone would have been unreachable in practice. + +The resolver reads the caller's explicit value, then the configured per-model cap, +then the provider default, then nothing — leaving the encoder fallback. It never +reads `contextWindow` or `modelContextWindows`: CompletionConfiguration #2 is the +output cap and #3 is the context window, and collapsing them would ask Cognition to +generate a whole window of output. + +The history ceiling is the other half and stays a separate quantity. #5189 is +carried with attribution: it derives the coding-agent projected-history bound from +the declared context window in characters. That bounds replayed history memory; +nothing there decides how long a reply may run. + +No retry change was needed. Source review of `stated-reset-retry.ts` and +`upstream-retry.ts` confirms an upstream `incomplete / max_output_tokens` is a +successful streaming response that has already emitted events, so it matches none +of the replay conditions. The repeated identical attempts in #5190 are the client's. + +### 11 — adapter event queue memory + +PR #5182 had the right idea and the wrong number. Its 1 MiB aggregate default +aborts a legitimate turn: a synchronous producer fills the queue before its +consumer is scheduled, and the image loop does exactly that with over a million +one-character deltas that coalesce into roughly 1.2 MB of retained text. Its own CI +proved it, which is why it sits at `CHANGES_REQUESTED`. + +The work is carried with attribution and reshaped around two budgets rather than +one, because a stalled consumer and a malformed event are different failures and an +operator reading the terminal error should learn which happened. Accounting is now +exact by construction: each queued item records what it was charged, so a merge +pays only for appended text, a refused event is priced before anything is retained +and never charged, and the terminal record explaining a refusal is admitted past +the budget it reports but still charged and released. `retainedCodeUnits()` exposes +the counter so the regressions assert it reaches zero rather than inferring it from +an abort that happened to fire. + +Retention is measured by a bounded walk of own enumerable properties rather than a +per-variant table. A table would be exhaustive over `AdapterEvent`, which is the +union class `AGENTS.md` records: a member added on another branch would silently +stop being counted. + +### 12 — per-admission-key model and provider scope + +The security question is where the check goes, not what it compares. A scope +evaluated against the client's string authorizes one destination and reaches +another, because alias resolution, policy and combo selection, subagent fallback +and compaction override all rewrite that string. So the scope names destinations +and is applied to the resolved route. + +On the Responses path every route produced by the request — direct name, alias, +policy, combo child, shadow-intercept target and both subagent-fallback re-routes — +passes through one capture point, which is where the check sits. Chat and Messages +translate into that path; their native lanes and the compaction route send without +re-entering it, so each applies the same predicate itself. `/v1/models` filters by +the same predicate, and that filter is explicitly not the boundary. + +A malformed scope drops the key rather than degrading to `undefined` like every +other field on the record, because degrading a permission field reads as "allowed +everything". + +Out of scope and deliberately not started: Redis, a full multi-tenant conversion, +and any budget or RPM/TPM system. + +#### What the scope does not cover, stated rather than implied + +An adversarial review of the branch found authenticated data-plane endpoints that +spend provider quota without resolving a model through the router, so the scope +does not reach them: + +- `/v1/images/generations` and `/v1/images/edits`, +- `/v1/audio/transcriptions` and its streaming form, +- `/v1/live`, `/v1/realtime/calls` and the standalone realtime sockets, +- the non-account-qualified branch of `/v1/alpha/search`, which forwards the caller's + model to a search sidecar without routing it. + +The account-qualified search branch does route a model and is checked. The rest +need a destination definition this lane does not own — an image or audio endpoint +has a fixed-purpose model rather than a routed one — and inventing one here would +be the multi-tenant expansion this batch rules out. They are recorded so the +contract is not read as broader than it is. + +The review also found that resolving an OpenAI virtual model rewrites +`route.modelId` to the wire id after the initial check. That one was a real hole in +the stated contract and is fixed: the settled route is re-checked after +normalization, so the id that is billed is the id that was authorized. + +## Verification + +Static source review plus exact-head hosted CI. No local suite, individual test, +typecheck, build, install or live `ocx` execution was run — those are NOT RUN, not +passing. + +Union-defect classes checked before pushing. No file in the touched set carries a +`file-size-baseline.json` cap; the largest, `src/oauth/index.ts` and +`src/server/index/serve-options.ts`, stay under the 2000-line new-file threshold. +The two new test files are registered in both `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. Nothing here restates a count or +enumerates a union. + +## Ownership + +Lane E owns the adapter event queue budget and the Devin and coding-agent limits. +Retry classification — `sendCount`, the send budget, the stage and cause vocabulary — +is lane C's and is untouched. + +## Carried work + +- #5182 (luvs01) — adapter event queue backlog budget. +- #5189 (mdwsk88) — coding-agent projected-history ceiling. + +Both carry a `Co-authored-by` trailer in the branch commit. Neither original pull +request is closed here; the coordinator handles that after this lane lands. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ef1c68922bb..7522f69ddae 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1566,7 +1566,9 @@ "web-search-sidecar-429.test.ts": "web-search", "management-google-tool-schema-policy.test.ts": "server", "codex-shim-destroyed-probe.test.ts": "codex-integration", - "client-runtime.test.ts": "clients" + "client-runtime.test.ts": "clients", + "devin-output-budget.test.ts": "providers", + "api-key-model-scope.test.ts": "server" }, "migrated": [ "adapters", diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 779e88d14f7..628341bb8d1 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -21,6 +21,30 @@ export const MAX_STREAM_LINE_BYTES = 8 * 1024 * 1024; export const MAX_STREAM_TOTAL_BYTES = 64 * 1024 * 1024; /** Hard ceiling on projected conversation history text (characters) to prevent runaway memory. */ export const MAX_PROJECTED_HISTORY_CHARS = 200_000; +/** + * Chars-per-token ratio for deriving the projected-history ceiling from the model context + * window. Sits between the English/code (~4 chars per token) and CJK (~1.5) extremes: the + * ceiling is a runaway-memory bound and a coarse guard against cutting history the window can + * hold, not a token accounting - the caller-side compaction line stays the token authority. + */ +const PROJECTED_HISTORY_CHARS_PER_TOKEN = 3; +/** Absolute ceiling on a window-derived history cap, so runaway metadata cannot unbound stdin. */ +const MAX_PROJECTED_HISTORY_DERIVED_CHARS = 4_000_000; + +/** + * Projected-history character ceiling for a turn, derived from the declared model context + * window. A missing or non-finite window keeps the legacy flat cap, and the derivation never + * lowers the cap below it: small windows change nothing, while large windows scale (a 1M-token + * model keeps 3M characters) until the hard ceiling. The flat 200k cap predates window + * metadata and cut long replays to roughly 50k-130k tokens of content regardless of the model. + */ +export function projectedHistoryCharLimit(contextWindowTokens: number | undefined): number { + if (typeof contextWindowTokens !== "number" || !Number.isFinite(contextWindowTokens) || contextWindowTokens <= 0) { + return MAX_PROJECTED_HISTORY_CHARS; + } + const derived = contextWindowTokens * PROJECTED_HISTORY_CHARS_PER_TOKEN; + return Math.min(Math.max(derived, MAX_PROJECTED_HISTORY_CHARS), MAX_PROJECTED_HISTORY_DERIVED_CHARS); +} export class CodingAgentStreamLimitError extends Error { constructor(message: string) { @@ -395,7 +419,7 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined * prior conversation turns are structured as bounded context text with tool results as text, * clearly demarcated from the current user request. Codex retains tool control; vendor tools are never invoked. */ -export function buildConversationInput(parsed: OcxParsedRequest): string[] { +export function buildConversationInput(parsed: OcxParsedRequest, options: { maxHistoryChars?: number } = {}): string[] { const nonDev = parsed.context.messages.filter(m => m.role !== "developer"); if (nonDev.length === 0) { return [JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: "" }] } })]; @@ -475,10 +499,11 @@ export function buildConversationInput(parsed: OcxParsedRequest): string[] { const imageBlocks: WireContentPart[] = [...historyImageBlocks, ...currentImageBlocks]; + const maxHistoryChars = options.maxHistoryChars ?? MAX_PROJECTED_HISTORY_CHARS; let historyText = historyMessages.map(formatMessageForHistory).filter(Boolean).join("\n\n"); - if (historyText.length > MAX_PROJECTED_HISTORY_CHARS) { + if (historyText.length > maxHistoryChars) { historyText = `[Earlier conversation history truncated for length...]\n\n` + - historyText.slice(historyText.length - MAX_PROJECTED_HISTORY_CHARS); + historyText.slice(historyText.length - maxHistoryChars); } const combinedText = `Prior conversation context:\n\n${historyText}\n\nCurrent user request:\n\n${currentRequestText}`; diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index a264ce188fa..f13b1ff0ea3 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -1,8 +1,9 @@ import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; import { commandInvocation } from "../../lib/win-exec"; +import { modelRecordValue } from "../../reasoning-effort"; import type { IncomingMeta } from "../base"; -import { buildConversationInput, CodingAgentProtocolError, mapStreamMessageToEvents, readJsonLines, type StreamParseState } from "./protocol"; +import { buildConversationInput, CodingAgentProtocolError, mapStreamMessageToEvents, projectedHistoryCharLimit, readJsonLines, type StreamParseState } from "./protocol"; import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile"; /** Injectable spawn for tests; production uses node:child_process. */ @@ -264,7 +265,14 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { /* EPIPE if the CLI exits early; surfaced via close/stderr */ }); - for (const line of buildConversationInput(parsed)) stdin.write(`${line}\n`); + // The projected history scales with the model context window on the routed provider row + // (catalog and config metadata merged): a 1M-token model keeps 3M characters of replay + // where the flat cap cut it near 50k-130k tokens of content. Absent metadata keeps the + // flat cap. + const historyCharLimit = projectedHistoryCharLimit( + modelRecordValue(provider.modelContextWindows, parsed.modelId) ?? provider.contextWindow, + ); + for (const line of buildConversationInput(parsed, { maxHistoryChars: historyCharLimit })) stdin.write(`${line}\n`); stdin.end(); } const stdout = child.stdout; diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 5832b2be8b7..c1c67bdd409 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -192,6 +192,35 @@ async function resolveWireModelUid( */ export const resolveWireModelUidForTests = resolveWireModelUid; +const positiveTokenCount = (value: unknown): number | undefined => + typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; + +/** + * Read a per-model token count for the exact UID selected for this turn. + * + * Tries the selected UID and then its collapsed base id, preferring the + * canonical spelling and accepting dotted or case-folded saved hints — the same + * normalization the inference request applies to the model id. Where several + * spellings match one id, the smallest wins: a ceiling stated twice is + * satisfied by the lower statement. + */ +function devinModelTokenHint( + record: Record | undefined, + modelUid: string, +): number | undefined { + if (!record) return undefined; + for (const id of [modelUid, collapseDevinModelUid(modelUid)]) { + const exact = Object.hasOwn(record, id) ? positiveTokenCount(record[id]) : undefined; + if (exact !== undefined) return exact; + const matches = Object.entries(record) + .filter(([key]) => normalizeDevinModelId(key).toLowerCase() === id.toLowerCase()) + .map(([, value]) => positiveTokenCount(value)) + .filter((value): value is number => value !== undefined); + if (matches.length > 0) return Math.min(...matches); + } + return undefined; +} + /** * Resolve the INPUT ceiling for the exact UID selected for this turn. Catalog * ClientModelConfig #18 and CompletionConfiguration #3 both carry input tokens; @@ -204,33 +233,50 @@ function resolveDevinMaxInputTokens( modelUid: string, liveWindow?: number, ): number | undefined { - const positive = (value: unknown): number | undefined => - typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; - const baseId = collapseDevinModelUid(modelUid); - const configured = (record: Record | undefined): number | undefined => { - if (!record) return undefined; - for (const id of [modelUid, baseId]) { - // Prefer the canonical spelling; retain dotted/case-folded saved hints, - // matching the model-id normalization used for the inference request. - const exact = Object.hasOwn(record, id) ? positive(record[id]) : undefined; - if (exact !== undefined) return exact; - const matches = Object.entries(record) - .filter(([key]) => normalizeDevinModelId(key).toLowerCase() === id.toLowerCase()) - .map(([, value]) => positive(value)) - .filter((value): value is number => value !== undefined); - if (matches.length > 0) return Math.min(...matches); - } - return undefined; - }; - const contextHint = configured(provider.modelContextWindows) ?? positive(provider.contextWindow); - const inputHint = configured(provider.modelMaxInputTokens); - const ceilings = [positive(liveWindow), contextHint, inputHint] + const contextHint = devinModelTokenHint(provider.modelContextWindows, modelUid) + ?? positiveTokenCount(provider.contextWindow); + const inputHint = devinModelTokenHint(provider.modelMaxInputTokens, modelUid); + const ceilings = [positiveTokenCount(liveWindow), contextHint, inputHint] .filter((value): value is number => value !== undefined); return ceilings.length > 0 ? Math.min(...ceilings) : undefined; } -/** Pure test seam; runtime uses the same resolver immediately before dispatch. */ +/** + * Resolve the OUTPUT ceiling for this turn, highest authority first: + * + * 1. the caller's explicit `max_output_tokens`, forwarded unchanged — an + * explicit cap is a request, so a small one is never widened into a + * configured larger one; + * 2. the configured per-model cap (`modelMaxOutputTokens`), read through the + * same UID-aware hint lookup the input ceiling uses; + * 3. the provider-wide `defaultMaxOutputTokens`; + * 4. undefined, which leaves the cloud-direct encoder's own 8192 fallback in + * place for a provider that configured nothing. + * + * This is NOT the history ceiling, and the two must not collapse into one + * number. CompletionConfiguration #2 is the output cap and #3 is the context + * window, so feeding a context window into this resolver would ask Cognition to + * generate a whole window's worth of output. Nothing here reads + * `contextWindow` or `modelContextWindows` for that reason. + * + * Step 1 keeps the caller's raw value rather than `positiveTokenCount`: the + * inbound parser owns what a caller may send, and re-filtering here would + * silently promote a rejected value to a configured cap the caller never asked + * for. + */ +function resolveDevinMaxOutputTokens( + provider: OcxProviderConfig, + modelUid: string, + requested: number | undefined, +): number | undefined { + if (typeof requested === "number") return requested; + return devinModelTokenHint(provider.modelMaxOutputTokens, modelUid) + ?? positiveTokenCount(provider.defaultMaxOutputTokens); +} + +/** Pure test seams; runtime uses the same resolvers immediately before dispatch. */ export const resolveDevinMaxInputTokensForTests = resolveDevinMaxInputTokens; +export const resolveDevinMaxOutputTokensForTests = resolveDevinMaxOutputTokens; export class DevinMissingCredentialError extends Error { constructor() { @@ -590,6 +636,9 @@ export function createDevinAdapter( const maxInputTokens = resolveDevinMaxInputTokens( provider, modelUid, catalog?.byUid.get(modelUid)?.contextWindow, ); + const maxOutputTokens = resolveDevinMaxOutputTokens( + provider, modelUid, parsed.options.maxOutputTokens, + ); // The reset-retry wrapper waits out a 429 that states its own recovery // delay ("limit will reset in 35 seconds") and replays the identical // request — but only while zero events have been yielded, so a @@ -606,7 +655,7 @@ export function createDevinAdapter( // input hint used to force every model through the 128k default. completionOpts: { ...(maxInputTokens !== undefined ? { maxInputTokens } : {}), - ...(typeof parsed.options.maxOutputTokens === "number" ? { maxOutputTokens: parsed.options.maxOutputTokens } : {}), + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), ...(typeof parsed.options.temperature === "number" ? { temperature: parsed.options.temperature } : {}), ...(typeof parsed.options.topP === "number" ? { topP: parsed.options.topP } : {}), }, diff --git a/src/adapters/run-turn-queue.ts b/src/adapters/run-turn-queue.ts index 76024b32662..23c1cd50f18 100644 --- a/src/adapters/run-turn-queue.ts +++ b/src/adapters/run-turn-queue.ts @@ -6,11 +6,107 @@ export const PREFLIGHT_HEARTBEAT_RETAIN_LIMIT = 16; /** * Coalescing threshold for adjacent text/thinking deltas buffered with no - * waiting reader (UTF-16 code units). This is a merge-size ceiling, not a - * byte-memory cap: a single oversized incoming event stays one item. + * waiting reader (UTF-16 code units). This is a merge-size ceiling; the two + * retention budgets below are what bound memory. */ export const COALESCE_MAX_CHUNK_LENGTH = 64 * 1024; +/** + * Retained-string budget for the WHOLE queue, in UTF-16 code units. + * + * This bounds what the queue is holding at one moment, not how much a turn + * streams: every dequeue gives its charge back, so a long healthy stream with a + * consumer attached never accumulates and is never capped by total length. + * + * 32 MiB is deliberately far above any single legitimate burst. A synchronous + * producer can legally fill the queue before its consumer is scheduled — the + * image loop does exactly that with over a million one-character deltas, which + * coalesce into roughly 1.2 MB of retained text — so a budget near that size + * aborts healthy turns rather than stalled ones. The previous effective bound + * was the 1024-event cap times the 64 KiB merge ceiling, so 64 MiB; this halves + * it while leaving that legitimate burst an order of magnitude of headroom. + */ +export const DEFAULT_MAX_BACKLOG_CODE_UNITS = 32 * 1024 * 1024; + +/** + * Retained-string budget for ONE queued event, in UTF-16 code units. + * + * Separate from the aggregate on purpose, because the two describe different + * failures. Passing the aggregate means the consumer is not keeping up. Passing + * this one means a single event is malformed or unbounded, which stays true + * however empty the queue is, so it must be refused even with the whole + * aggregate free. They also report different terminal messages, so an operator + * reading the turn's error learns which happened. + * + * Like the aggregate, this governs what the queue RETAINS. An event handed + * straight to a waiting consumer is never held here, so neither budget applies + * to it: refusing it would abort a turn over memory this queue does not own, + * and the consumer's own per-event bound governs that payload instead. + */ +export const DEFAULT_MAX_EVENT_CODE_UNITS = 8 * 1024 * 1024; + +const BACKLOG_EXCEEDED_MESSAGE = "consumer stalled: adapter event backlog exceeded — turn aborted"; +const EVENT_TOO_LARGE_MESSAGE = "adapter event exceeds the single-event retained-string budget — turn aborted"; + +/** + * Bound on how far the retention measure walks into one event. AdapterEvent is + * a plain-data union, but two of its members carry open provider-shaped bags + * (`providerState`, `usage.rawUsage`) whose depth no type here controls. The + * ceilings keep a single push O(1)-ish rather than O(whatever an adapter + * attached), and under-counting a pathological object is the safe direction: + * the event-count cap still bounds how many of them can be retained. + */ +const RETENTION_MAX_DEPTH = 8; +const RETENTION_MAX_NODES = 4096; + +/** + * Retained UTF-16 code units carried by one event's string payload. + * + * Measured by walking own enumerable properties rather than by naming each + * variant's string fields: a hand-written per-variant table is exhaustive over + * a union, so adding an event type on one branch while a consumer lands on + * another produces a measure that silently stops counting the new payload. + * The walk is the derived answer and needs no update when the union grows. + * + * `type` is skipped because it is the discriminant, identical for every event + * of a kind and not payload anyone is buffering. + */ +export function retainedEventCodeUnits(event: AdapterEvent): number { + // Defensive: this measures values an adapter produced. A malformed emission + // has to become a terminal event, not a TypeError thrown out of push() with + // the queue half-updated. + if (!event || typeof event !== "object") return 0; + let total = 0; + let nodes = 0; + const seen = new Set(); + const visit = (value: unknown, depth: number): void => { + if (typeof value === "string") { + total += value.length; + return; + } + if (!value || typeof value !== "object" || depth >= RETENTION_MAX_DEPTH || seen.has(value)) return; + seen.add(value); + for (const nested of Object.values(value)) { + if (nodes++ >= RETENTION_MAX_NODES) return; + visit(nested, depth + 1); + } + }; + for (const [key, value] of Object.entries(event)) { + if (key === "type") continue; + if (nodes++ >= RETENTION_MAX_NODES) break; + visit(value, 1); + } + return total; +} + +function positiveBudget(value: number | undefined, fallback: number, name: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved <= 0) { + throw new RangeError(`${name} must be a positive safe integer`); + } + return resolved; +} + export interface AdapterEventQueue { /** * Returns true when the event was merged into the buffered tail instead of @@ -22,6 +118,13 @@ export interface AdapterEventQueue { close(): void; stream(): AsyncIterable; collect(): Promise; + /** + * Retained string payload the queue is currently holding, in UTF-16 code + * units. Exposed so a caller — and a regression — can assert the counter + * returns to zero on every terminal path instead of inferring it from an + * abort that happened to fire. + */ + retainedCodeUnits(): number; } export interface AdapterEventPreflight { @@ -73,47 +176,74 @@ export async function preflightAdapterEvents( export function createAdapterEventQueue(opts?: { maxBacklog?: number; + maxBacklogCodeUnits?: number; + maxEventCodeUnits?: number; onBacklogExceeded?: () => void; }): AdapterEventQueue { const queued: AdapterEvent[] = []; + /** + * What each queued item was charged, in lockstep with `queued`. Releasing the + * recorded charge rather than re-measuring is what makes the accounting exact + * on every path: a merge, a terminal record admitted past the budget and a + * plain event all give back precisely what they took, so the counter cannot + * drift positive (a leak) or negative (a budget the next turn gets for free). + */ + const charged: number[] = []; const readers: QueueReader[] = []; const maxBacklog = opts?.maxBacklog ?? 1_024; + const maxBacklogCodeUnits = positiveBudget(opts?.maxBacklogCodeUnits, DEFAULT_MAX_BACKLOG_CODE_UNITS, "maxBacklogCodeUnits"); + const maxEventCodeUnits = positiveBudget(opts?.maxEventCodeUnits, DEFAULT_MAX_EVENT_CODE_UNITS, "maxEventCodeUnits"); + let retained = 0; let closed = false; // Merge an incoming delta into the buffered tail when no reader is waiting. - // The backlog cap counts events, not tokens, so a detached or briefly - // stalled consumer (e.g. a Codex app mid-reconnect whose disconnect Bun has - // not yet delivered) used to hit the cap within seconds of token-granular - // streaming and abort a healthy turn. Adjacent same-phase text deltas, - // adjacent thinking deltas, and consecutive heartbeats carry no ordering - // information between themselves, so merging them preserves every consumer - // contract while making the cap approximate buffered items again. + // The event cap counts events, not tokens, so a detached or briefly stalled + // consumer (e.g. a Codex app mid-reconnect whose disconnect Bun has not yet + // delivered) used to hit it within seconds of token-granular streaming and + // abort a healthy turn. Adjacent same-phase text deltas, adjacent thinking + // deltas, and consecutive heartbeats carry no ordering information between + // themselves, so merging them preserves every consumer contract while making + // the cap approximate buffered items again. // Pushed objects may be retained by adapters, so the tail is REPLACED with - // a fresh object — never mutated (alias safety). - const coalesceIntoTail = (event: AdapterEvent): boolean => { - const tail = queued[queued.length - 1]; - if (!tail) return false; + // a fresh object — never mutated (alias safety). Returning the replacement + // instead of installing it lets push price the merge before committing to it. + const planTailMerge = (tail: AdapterEvent, event: AdapterEvent): AdapterEvent | null => { if (event.type === "heartbeat") { - if (tail.type !== "heartbeat") return false; + if (tail.type !== "heartbeat") return null; // Heartbeats carry no ordering between themselves, but the replay-unsafe // marker is not ordering — it is a latch. Dropping the incoming event // would discard the only record that Cursor already performed a local // side effect, and preflight would then permit an OAuth replay of it. if (event.replayUnsafe === true && tail.replayUnsafe !== true) { - queued[queued.length - 1] = { type: "heartbeat", replayUnsafe: true }; + return { type: "heartbeat", replayUnsafe: true }; } - return true; + return tail; } if (event.type === "text_delta" && tail.type === "text_delta" && tail.phase === event.phase) { - if (tail.text.length + event.text.length > COALESCE_MAX_CHUNK_LENGTH) return false; - queued[queued.length - 1] = { type: "text_delta", text: tail.text + event.text, phase: tail.phase }; - return true; + if (tail.text.length + event.text.length > COALESCE_MAX_CHUNK_LENGTH) return null; + return { type: "text_delta", text: tail.text + event.text, phase: tail.phase }; } if (event.type === "thinking_delta" && tail.type === "thinking_delta") { - if (tail.thinking.length + event.thinking.length > COALESCE_MAX_CHUNK_LENGTH) return false; - queued[queued.length - 1] = { type: "thinking_delta", thinking: tail.thinking + event.thinking }; - return true; + if (tail.thinking.length + event.thinking.length > COALESCE_MAX_CHUNK_LENGTH) return null; + return { type: "thinking_delta", thinking: tail.thinking + event.thinking }; } + return null; + }; + + /** + * Record why the turn is ending and close. The terminal error is admitted + * past both budgets — refusing to retain the explanation of a refusal would + * leave the consumer with a silent truncation — but it is charged like any + * other item so the counter stays exact through the final drain. + */ + const abortWith = (message: string): false => { + opts?.onBacklogExceeded?.(); + const terminal: AdapterEvent = { type: "error", message }; + const cost = retainedEventCodeUnits(terminal); + queued.push(terminal); + charged.push(cost); + retained += cost; + close(); return false; }; @@ -121,17 +251,35 @@ export function createAdapterEventQueue(opts?: { if (closed) return false; const reader = readers.shift(); if (reader) { + // Handed straight to a waiting consumer, so the queue retains nothing and + // charges nothing. Neither budget applies to an event it never holds. reader({ done: false, value: event }); return false; } - if (coalesceIntoTail(event)) return true; - if (queued.length >= maxBacklog) { - opts?.onBacklogExceeded?.(); - queued.push({ type: "error", message: "consumer stalled: adapter event backlog exceeded — turn aborted" }); - close(); - return false; + const tail = queued[queued.length - 1]; + const merged = tail ? planTailMerge(tail, event) : null; + if (merged && tail) { + const replacement = retainedEventCodeUnits(merged); + if (replacement > maxEventCodeUnits) return abortWith(EVENT_TOO_LARGE_MESSAGE); + // Charge only what the backlog actually gains. A merge keeps the tail's + // own fields, so the incoming event's duplicated phase is never retained + // twice and an unchanged tail costs nothing at all. + const delta = replacement - charged[charged.length - 1]!; + if (delta > maxBacklogCodeUnits - retained) return abortWith(BACKLOG_EXCEEDED_MESSAGE); + queued[queued.length - 1] = merged; + charged[charged.length - 1] = replacement; + retained += delta; + return true; } + const cost = retainedEventCodeUnits(event); + if (cost > maxEventCodeUnits) return abortWith(EVENT_TOO_LARGE_MESSAGE); + // Both refusals are priced before anything is retained, so an event that is + // turned away is never charged for. + if (queued.length >= maxBacklog) return abortWith(BACKLOG_EXCEEDED_MESSAGE); + if (cost > maxBacklogCodeUnits - retained) return abortWith(BACKLOG_EXCEEDED_MESSAGE); queued.push(event); + charged.push(cost); + retained += cost; return false; }; @@ -147,6 +295,7 @@ export function createAdapterEventQueue(opts?: { while (true) { const next = queued.shift(); if (next) { + retained -= charged.shift() ?? 0; yield next; continue; } @@ -165,5 +314,5 @@ export function createAdapterEventQueue(opts?: { return events; }; - return { push, close, stream, collect }; + return { push, close, stream, collect, retainedCodeUnits: () => retained }; } diff --git a/src/cli/access.ts b/src/cli/access.ts index 51b351f9042..9cc0ec091a2 100644 --- a/src/cli/access.ts +++ b/src/cli/access.ts @@ -12,6 +12,8 @@ import { const USAGE = `Usage: ocx access key [list] [--json] ocx access key create [name] [--json] + ocx access key get [--json] + ocx access key set [--allow-provider ]... [--allow-model ]... [--clear] [--json] ocx access key rotate [--json] ocx access key rotate commit [--json] ocx access key rotate abort [--json] @@ -65,6 +67,51 @@ function formatKeyRows(payload: Record, keys: Array 0 ? [...lines, "", ...footer] : lines; } +/** + * Repeatable option values, in the order given. + * + * takeOption removes one occurrence, so a scope with several entries needs the + * loop: reading it once would silently keep only the first `--allow-model` and + * write a narrower scope than the operator typed. + */ +function takeAllOptions(args: string[], name: string): string[] { + const values: string[] = []; + for (;;) { + const value = takeOption(args, name); + if (value === undefined) break; + values.push(value); + } + return values; +} + +/** + * Find a key by id or by name, without ever reading the secret. + * + * The management API keys every mutation by id, so a name has to be resolved + * here. An ambiguous name is refused rather than resolved to the first match: + * silently scoping one of two keys that share a name is the kind of mistake + * only discovered when the wrong client stops working. + */ +function findKeyRow(keys: Array>, selector: string): Record { + const wanted = selector.trim().toLowerCase(); + const byId = keys.filter(entry => String(entry.id ?? "").toLowerCase() === wanted); + if (byId.length === 1) return byId[0]!; + const byName = keys.filter(entry => String(entry.name ?? "").trim().toLowerCase() === wanted); + if (byName.length === 1) return byName[0]!; + if (byName.length > 1) throw new CliUsageError("key name " + selector + " is ambiguous; use the id", USAGE); + throw new CliUsageError("no API key matches " + selector, USAGE); +} + +function scopeLines(entry: Record): string[] { + const list = (value: unknown): string => + Array.isArray(value) && value.length > 0 ? (value as string[]).join(", ") : "(any)"; + return [ + "API key " + String(entry.name ?? "") + " (" + String(entry.id ?? "") + ")", + " allowed providers: " + list(entry.allowedProviders), + " allowed models: " + list(entry.allowedModels), + ]; +} + async function key(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const action = (args.shift() ?? "list").toLowerCase(); @@ -90,6 +137,46 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise { ]); return; } + if (action === "get") { + const selector = args.shift(); + if (!selector) throw new CliUsageError("key id or name is required", USAGE); + rejectArgs(args, USAGE); + const result = await runtimeRequest>("/api/keys", {}, deps); + const entry = findKeyRow(Array.isArray(result.keys) ? result.keys as Array> : [], selector); + // The list response carries the masked prefix and never the secret, so the + // row is safe to print as-is under --json. + printData(entry, wantsJson, scopeLines(entry)); + return; + } + if (action === "set") { + const selector = args.shift(); + if (!selector) throw new CliUsageError("key id or name is required", USAGE); + const clear = takeFlag(args, "--clear"); + const providers = takeAllOptions(args, "--allow-provider"); + const models = takeAllOptions(args, "--allow-model"); + rejectArgs(args, USAGE); + if (!clear && providers.length === 0 && models.length === 0) { + throw new CliUsageError("set requires --allow-provider, --allow-model, or --clear", USAGE); + } + const listed = await runtimeRequest>("/api/keys", {}, deps); + const target = findKeyRow(Array.isArray(listed.keys) ? listed.keys as Array> : [], selector); + // A set REPLACES the named dimension rather than appending to it, and + // --clear removes both. Naming one dimension leaves the other alone, so + // narrowing providers cannot accidentally widen models. + const body: Record = { id: target.id }; + if (clear) { + body.allowedProviders = null; + body.allowedModels = null; + } + if (providers.length > 0) body.allowedProviders = providers; + if (models.length > 0) body.allowedModels = models; + const result = await runtimeRequest>("/api/keys", { + method: "PATCH", + body: JSON.stringify(body), + }, deps); + printData(result, wantsJson, scopeLines(result)); + return; + } if (action === "rotate") { const operation = args[0] === "commit" || args[0] === "abort" ? args.shift()! : "start"; const id = args.shift(); diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index f3160644b08..93edd25d0fc 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -649,6 +649,13 @@ export const apiKeyEntrySchema = z.object({ createdAt: z.string().catch(""), // A damaged overlap record must never discard the still-authoritative key. pendingRotation: pendingApiKeyRotationSchema.optional().catch(undefined), + // Deliberately NOT `.catch`ed, unlike every field above. Degrading a damaged + // scope to `undefined` would silently widen the key to the whole catalog, + // which is the one direction a permission field must never fail. Letting the + // record fail instead drops the key, so a corrupted scope stops that client + // rather than promoting it. + allowedProviders: z.array(z.string().trim().min(1).max(256)).optional(), + allowedModels: z.array(z.string().trim().min(1).max(256)).optional(), }).passthrough(); /** diff --git a/src/oauth/index.ts b/src/oauth/index.ts index f33a6855c0b..5d8e00ef472 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1273,6 +1273,29 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [ // existing rows through enrichProviderFromRegistry, which is fill-only and // preserves explicit saved values. +/** + * Output-budget fields an OAuth preset may refresh but must never erase. + * + * These stay on the reconcile list so a preset that does declare a budget still + * refreshes the saved row. What changes is the other branch: when the preset + * declares nothing, the operator's value survives instead of being deleted. + * + * Without that, the fields behaved as if they could not be configured at all. + * No OAuth preset seeds either one, so the delete branch was the only branch + * these two ever took, and a hand-edited `defaultMaxOutputTokens` was gone + * before the first turn of the next startup — leaving the adapter's own + * fallback as the only reachable output cap (#5190). + * + * Scoped to the output budget on purpose. The input side (`contextWindow`, + * `modelContextWindows`) describes what the account's models are, which the + * preset and live discovery do own; an output budget is a spend decision the + * operator makes. + */ +const OAUTH_PRESERVE_WHEN_PRESET_UNSET: ReadonlySet = new Set([ + "defaultMaxOutputTokens", + "modelMaxOutputTokens", +]); + const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity"; const GOOGLE_ANTIGRAVITY_LIVE_DISCOVERY_VERSION = 2 as const; @@ -1312,7 +1335,7 @@ function applyOAuthPresetCatalog( if (JSON.stringify(provider[field]) === JSON.stringify(preset[field])) continue; if (preset[field] !== undefined) { provider[field] = cloneProviderField(preset[field]) as never; - } else { + } else if (!OAUTH_PRESERVE_WHEN_PRESET_UNSET.has(field)) { delete provider[field]; } } diff --git a/src/server/admission-model-scope.ts b/src/server/admission-model-scope.ts new file mode 100644 index 00000000000..4ffe9380827 --- /dev/null +++ b/src/server/admission-model-scope.ts @@ -0,0 +1,166 @@ +import type { DataPlaneAdmission } from "./auth-cors"; +import type { OcxApiKeyEntry, OcxConfig } from "../types"; + +/** + * Per-admission-key model and provider scope. + * + * A hub that serves several clients with their own `ocx_data_…` keys needs a + * mail or cron key to be unable to spend a coding key's Grok or Claude quota. + * Hub-level model selection cannot express that: it hides a model from + * everyone or from no one (#5049). + * + * Two rules decide what this is and is not: + * + * A scope names DESTINATIONS, not selectors. It is evaluated against the + * resolved route — the provider and model a turn will actually bill — never + * against the string the client sent. A client-supplied label is not a + * permission subject: alias resolution, a combo pick, a policy fallback and a + * compaction override all rewrite that string, so a scope checked before them + * would authorize one destination and reach another. + * + * A scope is never management authority. It narrows which models an inference + * key may call and grants nothing else; reading or editing a scope stays on + * the management credential. + */ +export interface AdmissionModelScope { + /** Resolved provider names this key may reach. Empty means every provider. */ + readonly providers: readonly string[]; + /** Resolved destinations this key may reach. Empty means every model. */ + readonly models: readonly string[]; +} + +/** The resolved destination a scope decision is made about. */ +export interface ScopedRoute { + readonly providerName: string; + readonly modelId: string; +} + +const normalize = (value: string): string => value.trim().toLowerCase(); + +function normalizedList(values: readonly string[] | undefined): readonly string[] { + if (!Array.isArray(values)) return []; + const seen = new Set(); + for (const value of values) { + if (typeof value !== "string") continue; + const normalized = normalize(value); + if (normalized) seen.add(normalized); + } + return [...seen]; +} + +/** Read the scope a stored key declares. An entry with neither list is unrestricted. */ +export function admissionModelScopeOf(entry: Pick): AdmissionModelScope | undefined { + const providers = normalizedList(entry.allowedProviders); + const models = normalizedList(entry.allowedModels); + return providers.length === 0 && models.length === 0 ? undefined : { providers, models }; +} + +/** + * The scope that applies to one request, or undefined when nothing is scoped. + * + * Only a configured key carries a scope. The environment token and loopback + * admission have no stored record to attach one to, so they stay unrestricted; + * an operator who wants them narrowed issues a configured key instead. + */ +export function resolveAdmissionModelScope( + config: Pick, + admission: DataPlaneAdmission | undefined, +): AdmissionModelScope | undefined { + if (!admission || admission.kind !== "configured") return undefined; + const entry = (config.apiKeys ?? []).find(key => key.id === admission.keyId); + return entry ? admissionModelScopeOf(entry) : undefined; +} + +/** + * Does this scope admit this resolved destination? + * + * The two lists are independent conditions and both must hold when both are + * declared: a key allowed one provider and one model may not reach that + * model on a different provider, which is what a combo child or a policy + * fallback would otherwise do while the requested selector stayed the same. + * + * A model entry matches the bare resolved model id or the fully qualified + * `provider/model` form, so an operator can scope one model everywhere or + * pin it to a single provider without a second field. + */ +export function routeAllowedByScope( + scope: AdmissionModelScope | undefined, + route: ScopedRoute, +): boolean { + if (!scope) return true; + const provider = normalize(route.providerName); + const model = normalize(route.modelId); + if (scope.providers.length > 0 && !scope.providers.includes(provider)) return false; + if (scope.models.length === 0) return true; + return scope.models.includes(model) || scope.models.includes(provider + "/" + model); +} + +/** + * A request that asked for a destination its key may not reach. + * + * Carries the selector the client sent rather than the destination it resolved + * to: the caller needs to know which of its own requests was refused, and a + * key that may not reach a provider has no business learning that an alias it + * named points there. The resolved destination goes to the server log. + */ +export class AdmissionModelDeniedError extends Error { + readonly requestedModel: string; + readonly deniedProvider: string; + readonly deniedModel: string; + constructor(requestedModel: string, route: ScopedRoute) { + super("model " + requestedModel + " is not allowed for this API key"); + this.name = "AdmissionModelDeniedError"; + this.requestedModel = requestedModel; + this.deniedProvider = route.providerName; + this.deniedModel = route.modelId; + } +} + +/** Stable wire type for a scope refusal. */ +export const MODEL_NOT_ALLOWED_FOR_KEY = "model_not_allowed_for_key"; + +/** The HTTP body a scope refusal returns. 403: authenticated, not permitted. */ +export function admissionModelDeniedBody(error: AdmissionModelDeniedError): { + error: { type: string; message: string; model: string }; +} { + return { + error: { + type: MODEL_NOT_ALLOWED_FOR_KEY, + message: error.message, + model: error.requestedModel, + }, + }; +} + +/** + * The refusal a scoped request gets: 403, not 404. + * + * The key authenticated; it simply may not reach this destination. Reporting + * "not found" instead would tell a client its credential is wrong and invite + * it to retry with another, and would make an operator's own denial + * indistinguishable from a typo in the model name. + */ +export function admissionModelDeniedResponse(error: AdmissionModelDeniedError): Response { + return new Response(JSON.stringify(admissionModelDeniedBody(error)), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Refuse a resolved destination this key may not reach. + * + * Every request-path site that produces or re-produces a route calls this, so + * the direct name, an alias, a combo child, a policy or subagent fallback and + * a compaction override are all checked at the point they become concrete + * rather than once at the front door. + */ +export function assertRouteAllowedByScope( + scope: AdmissionModelScope | undefined, + requestedModel: string, + route: ScopedRoute, +): void { + if (!routeAllowedByScope(scope, route)) { + throw new AdmissionModelDeniedError(requestedModel, route); + } +} diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 8a611e70e4b..4f4209d11c0 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -46,6 +46,12 @@ import { providerConsumesCallerAuthorization } from "../providers/caller-authori import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar"; import { captureCallerDirectAuth } from "../providers/caller-authorization"; import type { AdmissionLease } from "../lib/admission"; +import { + admissionModelDeniedResponse, + AdmissionModelDeniedError, + assertRouteAllowedByScope, + resolveAdmissionModelScope, +} from "./admission-model-scope"; import type { DataPlaneAdmission } from "./auth-cors"; import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission"; import { @@ -150,6 +156,10 @@ async function handleChatCompletionsWithBudget( let chatNativeRoute: ReturnType | null = null; try { const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody)); + // The native Chat lane sends without re-entering the Responses path, so it + // has to apply the key's scope itself. Translated traffic is checked where + // every rewrite converges instead. + assertRouteAllowedByScope(resolveAdmissionModelScope(config, logIds?.admission), requestedModel, route); // Preserve the routed destination for Go recognition, then settle the wire before // deriving protocol-scoped affinity. Recognition must not inspect the flipped adapter. const routedProvider = route.provider; @@ -182,6 +192,11 @@ async function handleChatCompletionsWithBudget( // effort, failover, and per-attempt telemetry run before any native Chat send. if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) chatNativeRoute = route; } catch (err) { + if (err instanceof AdmissionModelDeniedError) { + logCtx.requestedModel = requestedModel; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" }); + return admissionModelDeniedResponse(err); + } if (err instanceof UnknownRoutingPolicyError) { logCtx.requestedModel = requestedModel; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index b73199dd15a..b27ec73c91e 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -7,6 +7,12 @@ * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape. */ import { FORWARD_HEADERS } from "../adapters/openai-responses"; +import { + admissionModelDeniedResponse, + AdmissionModelDeniedError, + assertRouteAllowedByScope, + resolveAdmissionModelScope, +} from "./admission-model-scope"; import { jsonUtf8Bytes } from "../lib/json-byte-size"; import { sseFieldValue } from "../lib/sse-decoder"; import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; @@ -815,6 +821,13 @@ async function handleClaudeMessagesWithBudget( // verified live 2026-07-11). Strip them for that route; routed providers keep them. try { const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); + // Same reason as the native Chat lane: this route can be sent from here, so + // the key's scope is applied before the wire is settled. + assertRouteAllowedByScope( + resolveAdmissionModelScope(config, logIds?.admission), + String(internalBody.model ?? ""), + route, + ); // Settle the wire once so the sampling decision below reads the effective // adapter rather than the provider-wide default (#404). route.staticPolicy = captureRouteStaticPolicy( @@ -847,6 +860,11 @@ async function handleClaudeMessagesWithBudget( if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning; } } catch (err) { + if (err instanceof AdmissionModelDeniedError) { + logCtx.requestedModel = requestedModel; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" }); + return admissionModelDeniedResponse(err); + } if (err instanceof UnknownRoutingPolicyError) { logCtx.requestedModel = requestedModel; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 657f5f0b5b9..6d3ed873339 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -100,6 +100,7 @@ import { withCors, withManagementCors, } from "../auth-cors"; +import { resolveAdmissionModelScope, routeAllowedByScope } from "../admission-model-scope"; import { disableResponsesRequestTimeout, handleResponses, @@ -1101,6 +1102,17 @@ export function createServeOptions(ctx: ServeOptionsContext) { return disabledModels.has(id) ? [] : [{ id, metadataId }]; }) ); + // What a scoped key may see, filtered by the same predicate that refuses + // it on the data plane, so the catalog and the send path cannot disagree. + // This is a convenience, never the boundary: hiding a row only stops a + // client that reads the catalog first, which is why the refusal lives on + // the request path and this filter reuses it rather than replacing it. + // Filtering happens where the resolved provider and model are still in + // hand -- a published id is a selector, and re-resolving one here would + // re-run combo selection just to render a list. + const listScope = resolveAdmissionModelScope(config, admission); + const listAllows = (providerName: string, modelId: string): boolean => + routeAllowedByScope(listScope, { providerName, modelId }); // The projection is opt-in. Keep the default path free of Cursor install detection, // and resolve the bundle table once for the whole list rather than once per row. const effortRowsEnabled = config.cursorEffortRows === true; @@ -1133,7 +1145,9 @@ export function createServeOptions(ctx: ServeOptionsContext) { effortRowKnownIds, )); }; - const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered) + .filter(m => listAllows(m.provider, m.id)) + .map(async m => { // Same rule as the anthropic branch: with the global fast switch on, a client // that has no Fast toggle is offered the fast identity directly. An operator // alias is an explicit decision and still wins. @@ -1180,8 +1194,12 @@ export function createServeOptions(ctx: ServeOptionsContext) { )); })); const data = [ - ...visibleNatives.flatMap(id => expandedNativeModelRow(id)), - ...visibleAccountNatives.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)), + ...visibleNatives + .filter(id => listAllows(OPENAI_CODEX_PROVIDER_ID, id)) + .flatMap(id => expandedNativeModelRow(id)), + ...visibleAccountNatives + .filter(({ metadataId }) => listAllows(OPENAI_CODEX_PROVIDER_ID, metadataId)) + .flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)), ...routedRows.flat(), ]; return jsonResponse({ object: "list", data }, 200, req, policy); diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 4cd52004665..0cb29ff8acd 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -869,6 +869,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< name: k.name, prefix: k.key.slice(0, 17) + "...", createdAt: k.createdAt, + // Scope is metadata, not secret: an operator has to be able to read + // what a key may reach without minting a replacement to find out. + ...(k.allowedProviders ? { allowedProviders: [...k.allowedProviders] } : {}), + ...(k.allowedModels ? { allowedModels: [...k.allowedModels] } : {}), ...(k.pendingRotation ? { pendingRotation: { id: k.pendingRotation.id, createdAt: k.pendingRotation.createdAt, @@ -952,15 +956,46 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await readJsonBody(req); if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config); if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config); - const nameField = validateKeyName(body.name, { required: true }); - if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config); const entry = (config.apiKeys ?? []).find(k => k.id === body.id); if (!entry) return jsonResponse({ error: "key not found" }, 404, req, config); - entry.name = nameField.value; + // Rename and scope are independent edits. A scope-only PATCH must not have + // to restate the name, and a rename must not silently widen a scope, so + // each field is applied only when the caller actually sent it. + const renaming = body.name !== undefined; + const scopingProviders = body.allowedProviders !== undefined; + const scopingModels = body.allowedModels !== undefined; + if (!renaming && !scopingProviders && !scopingModels) { + return jsonResponse({ error: "name, allowedProviders or allowedModels required" }, 400, req, config); + } + if (renaming) { + const nameField = validateKeyName(body.name, { required: true }); + if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config); + entry.name = nameField.value; + } + for (const [field, sent] of [["allowedProviders", scopingProviders], ["allowedModels", scopingModels]] as const) { + if (!sent) continue; + const value = body[field]; + // `null` and `[]` both clear the list back to unrestricted; anything else + // must be a list of non-empty strings, because a silently ignored malformed + // scope would read as "allowed everything" to whoever set it. + if (value === null) { delete entry[field]; continue; } + if (!Array.isArray(value) || value.some(item => typeof item !== "string" || !item.trim() || item.length > 256)) { + return jsonResponse({ error: `${field} must be a list of non-empty names` }, 400, req, config); + } + const normalized = [...new Set((value as string[]).map(item => item.trim()))]; + if (normalized.length === 0) delete entry[field]; + else entry[field] = normalized; + } saveConfigPreservingClaudeCode(config); reconcileLiveStateStores(); // Never echo key material from a rename. - return jsonResponse({ id: entry.id, name: entry.name, createdAt: entry.createdAt }, 200, req, config); + return jsonResponse({ + id: entry.id, + name: entry.name, + createdAt: entry.createdAt, + ...(entry.allowedProviders ? { allowedProviders: [...entry.allowedProviders] } : {}), + ...(entry.allowedModels ? { allowedModels: [...entry.allowedModels] } : {}), + }, 200, req, config); } if (url.pathname === "/api/keys" && req.method === "DELETE") { diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 92e394751bc..89e40123789 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1,4 +1,10 @@ import { capturePoolQuotaWriter } from "../../codex/account-store"; +import { + admissionModelDeniedResponse, + AdmissionModelDeniedError, + assertRouteAllowedByScope, + resolveAdmissionModelScope, +} from "../admission-model-scope"; import type { Server } from "bun"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { @@ -655,7 +661,12 @@ export async function handleResponsesCompact( // routes ordinary turns elsewhere (#2901); the compaction-scoped router // may land that on the configured default provider instead of 404. route = routeCompactionModel(config, compactModel, evidenceFromBody(raw)); + // A compaction override picks the model, not the caller, so the key's scope + // is applied to what the override resolved to rather than to the selector + // the client sent. + assertRouteAllowedByScope(resolveAdmissionModelScope(config, admission), compactRequestedModel, route); } catch (err) { + if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoEligiblePolicyCandidateError) { // Persist the evaluation trace (per-candidate exclusions + the // no-eligible reason) so a failed compact policy request stays diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 4d94d6a71ad..a96a672ada1 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -110,6 +110,13 @@ import { CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE, } from "../../codex/loopback-target"; import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission"; +import { + admissionModelDeniedResponse, + AdmissionModelDeniedError, + assertRouteAllowedByScope, + resolveAdmissionModelScope, + routeAllowedByScope, +} from "../admission-model-scope"; import { nativeContextLimits } from "../../codex/catalog"; import { streamingContextOverflowResponse } from "./context-overflow"; import { @@ -411,7 +418,18 @@ export async function prepareResponsesRequest( let route: RouteResult; let credentialDomainWasRewritten = false; + // The selector the caller actually sent, captured before shadow interception + // or a subagent fallback rewrites it, so a refusal names the client's own + // request rather than a destination it never asked for. + const inboundSelector = parsed.modelId; + const admissionScope = resolveAdmissionModelScope(config, options.admission); const captureInboundRoutePolicy = (candidate: RouteResult): RouteResult => { + // Every route this request path produces passes through here: the direct + // name, an alias, a policy or combo selection, a compaction override, a + // shadow-intercept target and both subagent-fallback re-routes. Checking + // the key's scope at this one point is what stops a rewrite from reaching + // a destination the front door would have refused. + assertRouteAllowedByScope(admissionScope, inboundSelector, candidate); candidate.staticPolicy = captureRouteStaticPolicy( candidate.providerName, candidate.modelId, @@ -474,6 +492,7 @@ export async function prepareResponsesRequest( } logCtx.routeDecision = route.routeDecision; } catch (err) { + if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { return comboUnavailable(err.comboId); } @@ -673,6 +692,7 @@ export async function prepareResponsesRequest( credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; } catch (err) { + if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { return comboUnavailable(err.comboId); } @@ -873,6 +893,7 @@ export async function prepareResponsesRequest( credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; } catch (err) { + if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { return comboUnavailable(err.comboId); } @@ -1021,6 +1042,14 @@ export async function prepareResponsesRequest( inboundTransport: options.inboundTransport, claudeGoAffinity: options.claudeGoAffinity, }); + // Normalization is the last thing that can move the destination: resolving an + // OpenAI virtual model rewrites route.modelId to the wire id that will + // actually be billed. A scope checked only before this would authorize the + // public selector and send the wire model, so the settled route is checked + // once more here. + if (!routeAllowedByScope(admissionScope, route)) { + return admissionModelDeniedResponse(new AdmissionModelDeniedError(inboundSelector, route)); + } // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. if (route.codexAccountNamespace) { diff --git a/src/server/search.ts b/src/server/search.ts index e66a51e24b7..6f9143ddaca 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -25,6 +25,12 @@ import { codexAccountNamespaceForModel } from "../codex/account-namespace-match" import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models"; import { isCodexReserveRequestEligible } from "../codex/loopback-target"; import type { DataPlaneAdmission } from "./auth-cors"; +import { + admissionModelDeniedResponse, + AdmissionModelDeniedError, + resolveAdmissionModelScope, + routeAllowedByScope, +} from "./admission-model-scope"; import { formatCodexProviderForLog } from "../codex/routing"; import { signalWithTimeout } from "../lib/abort"; import { readBoundedResponseBytes } from "../lib/bounded-body"; @@ -87,6 +93,12 @@ export async function handleSearch( if (!route.codexAccountId || route.codexAccountNamespace !== accountNamespace) { return formatErrorResponse(400, "invalid_request_error", "Invalid Codex account-qualified search model"); } + // This branch resolves a model through the router and bills the account it + // names, so a scoped key is held to the same destination rule it is held + // to on the inference path. + if (!routeAllowedByScope(resolveAdmissionModelScope(config, admission), route)) { + return admissionModelDeniedResponse(new AdmissionModelDeniedError(model, route)); + } exactAccount = { accountId: route.codexAccountId, modelId: route.modelId }; logCtx.provider = `${route.providerName}-${accountNamespace}`; logCtx.routeDecision = route.routeDecision; diff --git a/src/types/config.ts b/src/types/config.ts index 977bdb266f6..bd6959ce5ab 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -253,6 +253,21 @@ export interface OcxApiKeyEntry { key: string; createdAt: string; pendingRotation?: OcxPendingApiKeyRotation; + /** + * Resolved provider names this key may reach. Absent or empty means every + * provider, which is what every existing key has, so adding the field + * changes nothing until an operator sets one. + */ + allowedProviders?: string[]; + /** + * Resolved destinations this key may reach, as a bare model id or a + * `provider/model` pair. Absent or empty means every model. + * + * These name destinations, not the selectors a client sends: they are + * checked after alias, combo, fallback and compaction resolution, because + * that is the only point at which the model about to be billed is known. + */ + allowedModels?: string[]; } export interface OcxPendingApiKeyRotation { diff --git a/tests/adapters/run-turn-queue.test.ts b/tests/adapters/run-turn-queue.test.ts index e983ab6a00a..b1f821d4818 100644 --- a/tests/adapters/run-turn-queue.test.ts +++ b/tests/adapters/run-turn-queue.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { COALESCE_MAX_CHUNK_LENGTH, createAdapterEventQueue, PREFLIGHT_HEARTBEAT_RETAIN_LIMIT, preflightAdapterEvents } from "../../src/adapters/run-turn-queue"; +import { + COALESCE_MAX_CHUNK_LENGTH, + createAdapterEventQueue, + DEFAULT_MAX_BACKLOG_CODE_UNITS, + DEFAULT_MAX_EVENT_CODE_UNITS, + PREFLIGHT_HEARTBEAT_RETAIN_LIMIT, + preflightAdapterEvents, + retainedEventCodeUnits, +} from "../../src/adapters/run-turn-queue"; import type { AdapterEvent } from "../../src/types"; const text = (value: string): AdapterEvent => ({ type: "text_delta", text: value }); @@ -340,3 +348,173 @@ describe("run-turn adapter event preflight", () => { expect(cancelled).toBe(1); }); }); + +describe("run-turn adapter event queue retained-payload budgets", () => { + const BACKLOG_EXCEEDED = "consumer stalled: adapter event backlog exceeded — turn aborted"; + const EVENT_TOO_LARGE = "adapter event exceeds the single-event retained-string budget — turn aborted"; + + test("a stalled consumer is bounded by retained payload, not by the event count alone", async () => { + let backlogExceeded = 0; + const queue = createAdapterEventQueue({ + maxBacklogCodeUnits: 8, + maxEventCodeUnits: 8, + onBacklogExceeded: () => { backlogExceeded += 1; }, + }); + + // Each event on its own is within the per-event budget, so only the + // aggregate can be what refuses the second one. + expect(retainedEventCodeUnits(toolStart("0"))).toBeLessThanOrEqual(8); + queue.push(toolStart("0")); + queue.push(toolStart("1")); + + expect(backlogExceeded).toBe(1); + expect(await queue.collect()).toEqual([ + toolStart("0"), + { type: "error", message: BACKLOG_EXCEEDED }, + ]); + expect(queue.retainedCodeUnits()).toBe(0); + }); + + test("one oversized event is refused with its own cause even into an empty queue", async () => { + let backlogExceeded = 0; + const queue = createAdapterEventQueue({ + maxEventCodeUnits: 8, + onBacklogExceeded: () => { backlogExceeded += 1; }, + }); + + // The aggregate has its whole default budget free; this refusal is about + // the single event, and it has to say so rather than blame the consumer. + queue.push(text("x".repeat(9))); + + expect(backlogExceeded).toBe(1); + expect(await queue.collect()).toEqual([{ type: "error", message: EVENT_TOO_LARGE }]); + expect(queue.retainedCodeUnits()).toBe(0); + }); + + test("accumulated coalescing is charged by retained growth, not once per merged delta", async () => { + let backlogExceeded = 0; + const queue = createAdapterEventQueue({ + maxBacklogCodeUnits: 16, + onBacklogExceeded: () => { backlogExceeded += 1; }, + }); + + // Three 2-character deltas sharing one 10-character phase. Retained growth + // is 12 then +2 then +2; charging each whole event instead would bill 36 + // and abort a turn holding sixteen code units. + queue.push(phasedText("ab", "commentary")); + queue.push(phasedText("cd", "commentary")); + queue.push(phasedText("ef", "commentary")); + + expect(backlogExceeded).toBe(0); + expect(queue.retainedCodeUnits()).toBe(16); + queue.close(); + expect(await queue.collect()).toEqual([phasedText("abcdef", "commentary")]); + expect(queue.retainedCodeUnits()).toBe(0); + }); + + test("an event handed straight to a waiting consumer is never charged or capped", async () => { + let backlogExceeded = 0; + const queue = createAdapterEventQueue({ + maxBacklogCodeUnits: 4, + maxEventCodeUnits: 4, + onBacklogExceeded: () => { backlogExceeded += 1; }, + }); + const iterator = queue.stream()[Symbol.asyncIterator](); + + const pending = iterator.next(); + queue.push(text("x".repeat(1_000))); + + // The queue never held it, so neither budget has anything to say about it: + // both govern retained payload, and refusing this would abort a turn over + // memory the queue does not own. + expect(await pending).toEqual({ done: false, value: text("x".repeat(1_000)) }); + expect(backlogExceeded).toBe(0); + expect(queue.retainedCodeUnits()).toBe(0); + queue.close(); + }); + + test("a long synchronous burst well past one mebibyte still completes", async () => { + let backlogExceeded = 0; + const queue = createAdapterEventQueue({ + onBacklogExceeded: () => { backlogExceeded += 1; }, + }); + + // A synchronous producer legally fills the queue before its consumer is + // scheduled — the image loop does this with over a million one-character + // deltas, which coalesce into more than a mebibyte of retained text. A + // retained budget sized near that burst aborts healthy turns, so the + // default has to sit well above it. + const chunk = "x".repeat(64); + for (let i = 0; i < 20_000; i++) queue.push(text(chunk)); + queue.close(); + + const collected = await queue.collect(); + expect(backlogExceeded).toBe(0); + expect(collected.map(event => (event.type === "text_delta" ? event.text.length : 0)) + .reduce((sum, length) => sum + length, 0)).toBe(20_000 * 64); + expect(collected.every(event => event.type === "text_delta")).toBe(true); + expect(queue.retainedCodeUnits()).toBe(0); + }); + + test("draining after an abort releases exactly what was charged", async () => { + const queue = createAdapterEventQueue({ maxBacklogCodeUnits: 8, maxEventCodeUnits: 8 }); + + queue.push(toolStart("0")); + expect(queue.retainedCodeUnits()).toBeGreaterThan(0); + queue.push(toolStart("1")); + + // The terminal record is admitted past the budget it reports, and is then + // charged and released like any other item, so the counter lands on zero + // rather than on the size of an explanation nobody paid for. + const iterator = queue.stream()[Symbol.asyncIterator](); + expect(await iterator.next()).toEqual({ done: false, value: toolStart("0") }); + expect(await iterator.next()).toEqual({ done: false, value: { type: "error", message: BACKLOG_EXCEEDED } }); + expect(await iterator.next()).toEqual({ done: true, value: undefined }); + expect(queue.retainedCodeUnits()).toBe(0); + }); + + test("a cancel race leaves nothing charged behind", async () => { + const queue = createAdapterEventQueue({ maxBacklogCodeUnits: 64 }); + const iterator = queue.stream()[Symbol.asyncIterator](); + + queue.push(text("abcd")); + queue.push(thinking("wxyz")); + expect(await iterator.next()).toEqual({ done: false, value: text("abcd") }); + + // The consumer walks away mid-stream and the turn is closed underneath it. + await iterator.return?.(); + queue.close(); + queue.push(text("after close")); + + // What is still queued is still charged — and nothing more, so a second + // drain returns the counter to zero without a phantom balance. + expect(queue.retainedCodeUnits()).toBe(4); + expect(await queue.collect()).toEqual([thinking("wxyz")]); + expect(queue.retainedCodeUnits()).toBe(0); + }); + + test("the retention measure counts payload strings and skips the discriminant", () => { + expect(retainedEventCodeUnits(text("abcd"))).toBe(4); + expect(retainedEventCodeUnits(heartbeat)).toBe(0); + expect(retainedEventCodeUnits(phasedText("ab", "commentary"))).toBe(12); + // A malformed adapter emission has to become a terminal event, not a + // TypeError thrown out of push() with the queue half-updated. + expect(retainedEventCodeUnits(null as unknown as AdapterEvent)).toBe(0); + expect(retainedEventCodeUnits("oops" as unknown as AdapterEvent)).toBe(0); + // Nested provider-shaped payload is counted; a cycle terminates. + const cyclic: Record = { owner: "abc" }; + cyclic.self = cyclic; + expect(retainedEventCodeUnits({ type: "done", providerState: cyclic } as unknown as AdapterEvent)).toBe(3); + }); + + test("both budgets must be positive safe integers", () => { + const invalid = [Number.NaN, Number.POSITIVE_INFINITY, 0, -4, 2.5, Number.MAX_SAFE_INTEGER + 1]; + for (const value of invalid) { + expect(() => createAdapterEventQueue({ maxBacklogCodeUnits: value })) + .toThrow("maxBacklogCodeUnits must be a positive safe integer"); + expect(() => createAdapterEventQueue({ maxEventCodeUnits: value })) + .toThrow("maxEventCodeUnits must be a positive safe integer"); + } + expect(DEFAULT_MAX_EVENT_CODE_UNITS).toBeLessThan(DEFAULT_MAX_BACKLOG_CODE_UNITS); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d8a39f76b8b..308682ccebc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1398,5 +1398,7 @@ "gui-codex-usage-score-parity.test.ts": "gui", "web-search-sidecar-429.test.ts": "web-search", "codex-shim-destroyed-probe.test.ts": "codex-integration", - "client-runtime.test.ts": "clients" + "client-runtime.test.ts": "clients", + "devin-output-budget.test.ts": "providers", + "api-key-model-scope.test.ts": "server" } diff --git a/tests/oauth/oauth-provider-reconcile.test.ts b/tests/oauth/oauth-provider-reconcile.test.ts index fe624a54f70..8e8a1b67afd 100644 --- a/tests/oauth/oauth-provider-reconcile.test.ts +++ b/tests/oauth/oauth-provider-reconcile.test.ts @@ -504,6 +504,43 @@ describe("OAuth provider reconciliation", () => { expect(config.providers.kimi.requiresReasoningPlaceholderModels).toEqual([]); }); + test("preserves an operator output budget that no OAuth preset declares", () => { + // The devin preset declares neither output field, so the delete branch was + // the only branch either of them ever took: a hand-edited budget was gone + // before the next startup finished, leaving the adapter's own fallback as + // the only reachable cap (#5190). Reconciliation still owns the input-side + // catalog, which is why modelContextWindows is asserted alongside. + const preset = OAUTH_PROVIDERS.devin.providerConfig; + expect(preset.defaultMaxOutputTokens).toBeUndefined(); + expect(preset.modelMaxOutputTokens).toBeUndefined(); + const config = { + port: 10100, + defaultProvider: "devin", + googleAntigravityStaticCatalogVersion: 1, + providers: { + devin: { + ...structuredClone(preset), + defaultMaxOutputTokens: 64_000, + modelMaxOutputTokens: { "swe-2": 32_000 }, + modelContextWindows: { "swe-2": 1 }, + }, + }, + } satisfies OcxConfig; + + reconcileOAuthProviders(config, false); + + expect(config.providers.devin.defaultMaxOutputTokens).toBe(64_000); + expect(config.providers.devin.modelMaxOutputTokens).toEqual({ "swe-2": 32_000 }); + expect(config.providers.devin.modelContextWindows).toEqual(preset.modelContextWindows!); + + // A preset that does declare a budget still refreshes the saved row: this + // is a narrower delete branch, not an exemption from reconciliation. + config.providers.devin.defaultMaxOutputTokens = 1; + reconcileOAuthProviders(config, false); + expect(config.providers.devin.defaultMaxOutputTokens).toBe(1); + expect(OAUTH_PROVIDERS.anthropic.providerConfig.defaultMaxOutputTokens).toBeGreaterThan(0); + }); + test("refreshes Grok 4.6 levels while runtime fills the default without overwriting user intent", () => { const home = mkdtempSync(join(tmpdir(), "ocx-grok-46-reconcile-")); homes.push(home); diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index ea877f4a3eb..f68322752c5 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -252,6 +252,37 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(JSON.stringify(events)).not.toContain("secret-command"); }); + test("the projected history ceiling follows the model context window", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":7,"output_tokens":2}}\n'), + ]; + const history = Array.from({ length: 5 }, (_, index) => ({ + role: "user" as const, + content: "EARLY-MARKER-" + String(index) + " " + "a".repeat(50_000), + timestamp: index, + })); + const messages = [...history, { role: "user", content: "final request", timestamp: 5 }]; + + const wide = fakeChild(stdout); + const wideAdapter = createCodeBuddyAdapter( + provider({ modelContextWindows: { "glm-5.3": 1_000_000 } }), + { spawn: () => wide as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }, + ); + await run(wideAdapter, parsed({ context: { messages } })); + expect(wide.written.join("")).toContain("EARLY-MARKER-0"); + expect(wide.written.join("")).not.toContain("truncated for length"); + + const flat = fakeChild(stdout); + const flatAdapter = createCodeBuddyAdapter( + provider(), + { spawn: () => flat as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }, + ); + await run(flatAdapter, parsed({ context: { messages } })); + expect(flat.written.join("")).not.toContain("EARLY-MARKER-0"); + expect(flat.written.join("")).toContain("truncated for length"); + }); + test.each(["Bash", "exec", "shell", "apply_patch"])("refuses a bare %s DSML invoke", name => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event)); diff --git a/tests/providers/codebuddy-protocol.test.ts b/tests/providers/codebuddy-protocol.test.ts index a51cb1ea7b4..5c3b4c8db29 100644 --- a/tests/providers/codebuddy-protocol.test.ts +++ b/tests/providers/codebuddy-protocol.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; import { + MAX_PROJECTED_HISTORY_CHARS, buildConversationInput, buildInputLines, buildSystemPrompt, mapStreamMessageToEvents, + projectedHistoryCharLimit, readJsonLines, usageFromResult, } from "../../src/adapters/coding-agent/protocol"; @@ -352,3 +354,41 @@ describe("codebuddy conversation input builder (Strategy C projection)", () => { }); }); }); +describe("projected history ceiling derives from the model context window", () => { + test("absent or invalid window metadata keeps the legacy flat cap", () => { + expect(projectedHistoryCharLimit(undefined)).toBe(MAX_PROJECTED_HISTORY_CHARS); + expect(projectedHistoryCharLimit(Number.NaN)).toBe(MAX_PROJECTED_HISTORY_CHARS); + expect(projectedHistoryCharLimit(0)).toBe(MAX_PROJECTED_HISTORY_CHARS); + expect(projectedHistoryCharLimit(-1)).toBe(MAX_PROJECTED_HISTORY_CHARS); + }); + + test("a small window never lowers the cap below the legacy default", () => { + expect(projectedHistoryCharLimit(64_000)).toBe(MAX_PROJECTED_HISTORY_CHARS); + }); + + test("a large window scales the cap until the hard ceiling", () => { + expect(projectedHistoryCharLimit(128_000)).toBe(384_000); + expect(projectedHistoryCharLimit(1_000_000)).toBe(3_000_000); + expect(projectedHistoryCharLimit(Number.MAX_SAFE_INTEGER)).toBe(4_000_000); + }); + + test("buildConversationInput keeps the history a derived ceiling admits", () => { + const history = Array.from({ length: 5 }, (_, index) => ({ + role: "user" as const, + content: "EARLY-MARKER-" + String(index) + " " + "a".repeat(50_000), + timestamp: index, + })); + const messages = [...history, { role: "user", content: "current request", timestamp: 5 }]; + const parsed = parsedRequest({ context: { messages } }); + + const wide = buildConversationInput(parsed, { maxHistoryChars: projectedHistoryCharLimit(1_000_000) }) + .map(line => JSON.parse(line)); + expect(wide[0].message.content[0].text).toContain("EARLY-MARKER-0"); + expect(wide[0].message.content[0].text).not.toContain("truncated for length"); + + const flat = buildConversationInput(parsed).map(line => JSON.parse(line)); + expect(flat[0].message.content[0].text).not.toContain("EARLY-MARKER-0"); + expect(flat[0].message.content[0].text).toContain("truncated for length"); + expect(flat[0].message.content[0].text).toContain("current request"); + }); +}); diff --git a/tests/providers/devin-output-budget.test.ts b/tests/providers/devin-output-budget.test.ts new file mode 100644 index 00000000000..ebd0b277259 --- /dev/null +++ b/tests/providers/devin-output-budget.test.ts @@ -0,0 +1,183 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createDevinAdapter, resolveDevinMaxOutputTokensForTests } from "../../src/adapters/devin"; +import { parseCatalogBuffer, setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; +import { devinCacheIdentity, invalidateSessionIdentity } from "../../src/adapters/devin/cloud-direct/chat"; +import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * The output budget a Devin turn actually sends. + * + * CompletionConfiguration #2 is the output cap and #3 is the context window. + * Every assertion below reads both, because the defect these guard against is + * not "the number is wrong" but "the two meanings were collapsed into one": + * a caller that names no cap has to reach the configured output budget without + * the context window leaking into the field that decides how long the answer + * may run. + */ +describe("Devin output budget on the wire", () => { + const apiKey = "ocx-devin-output-fixture"; + const host = "https://server.codeium.com"; + const previousHome = process.env.OPENCODEX_HOME; + const previousFetch = globalThis.fetch; + let home = ""; + let requests: Buffer[] = []; + + function frame(body: Buffer, flags = 0): Buffer { + const header = Buffer.alloc(5); + header[0] = flags; + header.writeUInt32BE(body.length, 1); + return Buffer.concat([header, body]); + } + function fields(buf: Buffer) { + return new Map([...iterFields(buf)].map(field => [field.num, field])); + } + function seed(rows: Array<{ uid: string; window?: number }>): void { + const buffer = Buffer.concat(rows.map(row => encodeMessage(1, Buffer.concat([ + encodeString(1, row.uid), + encodeString(22, row.uid), + ...(row.window === undefined ? [] : [encodeVarintField(18, row.window)]), + encodeVarintField(4, 0), + ])))); + setCachedCatalogForTests(parseCatalogBuffer(buffer, apiKey, host)); + } + async function run( + provider: Partial = {}, + options: OcxParsedRequest["options"] = {}, + modelId = "swe-2-high", + ): Promise { + const adapter = createDevinAdapter({ ...provider, adapter: "devin", apiKey, baseUrl: host }); + const events: AdapterEvent[] = []; + await adapter.runTurn!({ + modelId, stream: true, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + options, + }, { headers: new Headers(), translatorBudget: createTranslatorBudget() }, + event => { events.push(event); }); + return events; + } + /** The completion configuration the one captured turn actually encoded. */ + function sentCompletion(): { output: bigint; context: bigint } { + expect(requests).toHaveLength(1); + const completion = fields(fields(requests[0]!).get(8)!.value as Buffer); + return { output: completion.get(2)!.value as bigint, context: completion.get(3)!.value as bigint }; + } + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-devin-output-")); + process.env.OPENCODEX_HOME = home; + requests = []; + setCachedCatalogForTests(null); + seed([{ uid: "swe-2-high", window: 262_000 }, { uid: "swe-2-max", window: 1_000_000 }]); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (!url.endsWith("/GetChatMessage")) return new Response("unavailable", { status: 503 }); + requests.push(Buffer.from(await (init!.body as Blob).arrayBuffer()).subarray(5)); + return new Response(Buffer.concat([ + frame(Buffer.concat([encodeString(3, "ok"), encodeVarintField(5, 2)])), + frame(Buffer.from("{}"), 2), + ]), { headers: { "content-type": "application/connect+proto" } }); + }) as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = previousFetch; + setCachedCatalogForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + invalidateSessionIdentity(devinCacheIdentity(apiKey, host)); + removeTreeWithRetry(home); + }); + + test("a caller that names no cap still reaches the configured provider budget", async () => { + // Codex never sends max_output_tokens, so this is the path every real turn + // takes. Before the budget was wired the encoder's 8192 was the only cap a + // devin turn could ever have, whatever the operator configured. + const events = await run({ defaultMaxOutputTokens: 64_000 }); + expect(events.some(event => event.type === "error")).toBe(false); + expect(sentCompletion().output).toBe(64_000n); + }); + + test("a per-model budget outranks the provider default and follows the selected variant", async () => { + await run({ + defaultMaxOutputTokens: 64_000, + modelMaxOutputTokens: { "swe-2-high": 32_000, "swe-2-max": 100_000 }, + }); + expect(sentCompletion().output).toBe(32_000n); + }); + + test("an explicit small cap survives a much larger configured budget", async () => { + await run( + { defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "swe-2-high": 32_000 } }, + { maxOutputTokens: 64 }, + ); + expect(sentCompletion().output).toBe(64n); + }); + + test("the context window never becomes the output cap", async () => { + // The whole provider row describes input size and nothing else. The output + // field has to stay on the encoder default rather than inherit 262k, which + // would ask Cognition to generate an entire context window of tokens. + await run({ contextWindow: 200_000, modelContextWindows: { "swe-2-high": 180_000 } }); + const sent = sentCompletion(); + expect(sent.output).toBe(8192n); + expect(sent.context).toBe(180_000n); + }); + + test("a configured output budget does not disturb the input ceiling", async () => { + await run({ defaultMaxOutputTokens: 64_000 }); + expect(sentCompletion().context).toBe(262_000n); + }); +}); + +describe("Devin output budget resolution", () => { + const provider: Partial = { + adapter: "devin", + defaultMaxOutputTokens: 64_000, + modelMaxOutputTokens: { "swe-2-high": 32_000 }, + }; + + test("an explicit caller value is forwarded unchanged", () => { + expect(resolveDevinMaxOutputTokensForTests(provider as OcxProviderConfig, "swe-2-high", 64)).toBe(64); + }); + + test("an unconfigured provider leaves the encoder default in place", () => { + expect(resolveDevinMaxOutputTokensForTests({ adapter: "devin" } as OcxProviderConfig, "swe-2-high", undefined)) + .toBeUndefined(); + }); + + test("a dotted or case-folded saved hint still matches the selected uid", () => { + expect(resolveDevinMaxOutputTokensForTests( + { adapter: "devin", modelMaxOutputTokens: { "SWE.2-HIGH": 24_000 } } as OcxProviderConfig, + "swe-2-high", undefined, + )).toBe(24_000); + }); + + test("another variant's budget is never borrowed", () => { + expect(resolveDevinMaxOutputTokensForTests( + { adapter: "devin", modelMaxOutputTokens: { "swe-2-max": 100_000 } } as OcxProviderConfig, + "swe-2-high", undefined, + )).toBeUndefined(); + }); + + test.each([Number.NaN, Number.POSITIVE_INFINITY, 0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + "invalid configured metadata %p is ignored rather than encoded", + invalid => { + expect(resolveDevinMaxOutputTokensForTests( + { adapter: "devin", defaultMaxOutputTokens: invalid, modelMaxOutputTokens: { "swe-2": invalid } } as OcxProviderConfig, + "swe-2-high", undefined, + )).toBeUndefined(); + }, + ); + + test("the context window is not an output budget", () => { + expect(resolveDevinMaxOutputTokensForTests( + { adapter: "devin", contextWindow: 200_000, modelContextWindows: { "swe-2-high": 180_000 } } as OcxProviderConfig, + "swe-2-high", undefined, + )).toBeUndefined(); + }); +}); diff --git a/tests/server/api-key-model-scope.test.ts b/tests/server/api-key-model-scope.test.ts new file mode 100644 index 00000000000..51e45b7c8c6 --- /dev/null +++ b/tests/server/api-key-model-scope.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +// The schema this exercises is reached through `src/config`, the entry the +// runtime evaluates first. Importing `config/schema/*` directly enters that +// module cycle from the wrong end and throws a TDZ ReferenceError. +import { loadConfig } from "../../src/config"; +import { + admissionModelDeniedBody, + AdmissionModelDeniedError, + admissionModelDeniedResponse, + admissionModelScopeOf, + assertRouteAllowedByScope, + MODEL_NOT_ALLOWED_FOR_KEY, + resolveAdmissionModelScope, + routeAllowedByScope, +} from "../../src/server/admission-model-scope"; +import { routeModel } from "../../src/router"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const KEY = "ocx_data_" + "a".repeat(40); +const OTHER_KEY = "ocx_data_" + "b".repeat(40); + +const previousHome = process.env.OPENCODEX_HOME; +const homes: string[] = []; + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + for (const home of homes.splice(0)) removeTreeWithRetry(home); +}); + +/** Load a hand-written config.json the way a startup would, and report the keys that survived. */ +function loadKeysFrom(apiKeys: unknown[]): string[] { + const home = mkdtempSync(join(tmpdir(), "ocx-key-scope-")); + homes.push(home); + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "config.json"), JSON.stringify({ + port: 10100, + defaultProvider: "allowed", + providers: { allowed: { adapter: "openai-chat", baseUrl: "https://allowed.test/v1", models: ["small"] } }, + apiKeys, + })); + return (loadConfig().apiKeys ?? []).map(entry => entry.name); +} + +function configWithKey(scope: { allowedProviders?: string[]; allowedModels?: string[] }): Pick { + return { + apiKeys: [ + { id: "scoped", name: "mail", key: KEY, createdAt: "2026-01-01T00:00:00.000Z", ...scope }, + { id: "open", name: "coding", key: OTHER_KEY, createdAt: "2026-01-01T00:00:00.000Z" }, + ], + }; +} + +describe("per-key model and provider scope", () => { + test("a key with neither list is unrestricted", () => { + expect(admissionModelScopeOf({})).toBeUndefined(); + expect(admissionModelScopeOf({ allowedProviders: [], allowedModels: [] })).toBeUndefined(); + expect(routeAllowedByScope(undefined, { providerName: "xai", modelId: "grok-4.6" })).toBe(true); + }); + + test("only a configured key carries a scope", () => { + const config = configWithKey({ allowedProviders: ["zai-discount"] }); + expect(resolveAdmissionModelScope(config, { kind: "configured", keyId: "scoped", source: "bearer" })) + .toEqual({ providers: ["zai-discount"], models: [] }); + // The environment token and loopback have no stored record to attach a + // scope to, so narrowing them would be inventing a policy nobody wrote. + expect(resolveAdmissionModelScope(config, { kind: "environment", source: "bearer" })).toBeUndefined(); + expect(resolveAdmissionModelScope(config, { kind: "loopback", source: "loopback" })).toBeUndefined(); + expect(resolveAdmissionModelScope(config, undefined)).toBeUndefined(); + expect(resolveAdmissionModelScope(config, { kind: "configured", keyId: "open", source: "bearer" })).toBeUndefined(); + }); + + test("the two lists are independent conditions and both must hold", () => { + const scope = admissionModelScopeOf({ allowedProviders: ["zai-discount"], allowedModels: ["glm-5.3-flash"] })!; + expect(routeAllowedByScope(scope, { providerName: "zai-discount", modelId: "glm-5.3-flash" })).toBe(true); + // The allowed model on a forbidden provider is exactly what a combo child or + // a policy fallback reaches while the requested selector never changes. + expect(routeAllowedByScope(scope, { providerName: "openrouter", modelId: "glm-5.3-flash" })).toBe(false); + // The allowed provider carrying a forbidden model is the mirror case. + expect(routeAllowedByScope(scope, { providerName: "zai-discount", modelId: "grok-4.6" })).toBe(false); + }); + + test("a model entry matches bare or fully qualified, and folds case and spacing", () => { + const scope = admissionModelScopeOf({ allowedModels: [" ZAI-Discount/GLM-5.3-Flash "] })!; + expect(routeAllowedByScope(scope, { providerName: "zai-discount", modelId: "glm-5.3-flash" })).toBe(true); + // Pinned to that provider: the same model elsewhere is a different destination. + expect(routeAllowedByScope(scope, { providerName: "openrouter", modelId: "glm-5.3-flash" })).toBe(false); + const bare = admissionModelScopeOf({ allowedModels: ["glm-5.3-flash"] })!; + expect(routeAllowedByScope(bare, { providerName: "openrouter", modelId: "glm-5.3-flash" })).toBe(true); + }); + + test("an alias is judged by what it resolves to, not by the name the client sent", () => { + // The defect this guards: a bare alias names neither the provider nor the + // model, so a scope checked against the caller's string has nothing to + // match on and would authorize a destination it never saw. + const config = { + port: 10100, + defaultProvider: "allowed", + providers: { + allowed: { adapter: "openai-chat", baseUrl: "https://allowed.test/v1", models: ["small"] }, + forbidden: { + adapter: "openai-chat", baseUrl: "https://forbidden.test/v1", + models: ["expensive"], modelAliases: { expensive: "cheap" }, + }, + }, + } as unknown as OcxConfig; + const scope = admissionModelScopeOf({ allowedProviders: ["allowed"] })!; + + const routed = routeModel(config, "cheap"); + expect(routed).toMatchObject({ providerName: "forbidden", modelId: "expensive" }); + expect(routeAllowedByScope(scope, routed)).toBe(false); + expect(() => assertRouteAllowedByScope(scope, "cheap", routed)).toThrow(AdmissionModelDeniedError); + + const permitted = routeModel(config, "allowed/small"); + expect(() => assertRouteAllowedByScope(scope, "allowed/small", permitted)).not.toThrow(); + }); + + test("a refusal is 403 and names the caller's own selector", () => { + const error = new AdmissionModelDeniedError("gldf-flash", { providerName: "xai", modelId: "grok-4.6" }); + const body = admissionModelDeniedBody(error); + expect(body.error.type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + // The client learns which of its own requests was refused. A key that may + // not reach xai has no business learning that its alias points there. + expect(body.error.model).toBe("gldf-flash"); + expect(JSON.stringify(body)).not.toContain("grok-4.6"); + const response = admissionModelDeniedResponse(error); + // 403, not 404: the key authenticated and simply may not go there, and a + // 404 would tell the client its credential is wrong and invite a retry. + expect(response.status).toBe(403); + }); + + test("a malformed scope drops the key instead of widening it", () => { + const scoped = { key: KEY, id: "scoped", name: "mail", createdAt: "2026-01-01T00:00:00.000Z", allowedProviders: ["zai-discount"] }; + const open = { key: OTHER_KEY, id: "open", name: "coding", createdAt: "2026-01-01T00:00:00.000Z" }; + expect(loadKeysFrom([scoped, open])).toEqual(["mail", "coding"]); + + // Every other field on this record degrades to a default. These two must + // not: degrading a damaged permission field reads as "allowed everything", + // which is the one direction it can never fail. The damaged key is dropped + // and its still-valid neighbour survives, so one bad record does not take + // the whole array with it. + for (const damaged of [ + { ...scoped, allowedProviders: "zai-discount" }, + { ...scoped, allowedProviders: [""] }, + { ...scoped, allowedModels: [123] }, + { ...scoped, allowedModels: ["x".repeat(257)] }, + ]) { + expect(loadKeysFrom([damaged, open])).toEqual(["coding"]); + } + + // A degrading neighbour still degrades, so the fail-closed choice is scoped + // to the permission fields rather than hardening the whole record. + expect(loadKeysFrom([{ ...scoped, name: 7 }, open])).toHaveLength(2); + }); +}); From 9880c3cad2010d0a421d625b4497a113973f09a5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 16:31:14 +0900 Subject: [PATCH 08/87] fix(codex): stop a stopped proxy from locking users out of Codex sign-in (#5267) * fix(codex): name the undo command in injected Codex routing A Windows user whose proxy had stopped was locked out of Codex sign-in (#5261). The root openai_base_url opencodex writes keeps pointing Codex's built-in openai provider at 127.0.0.1:10100 after the proxy is gone, and the injection survives reboot, so the lockout persists. The only surface such a user can still read is config.toml, and it named no way out: the marker said "Auto-injected by opencodex" and nothing else. The recovery they found was to hand-delete the routing lines and the catalog file, which is worse than "ocx restore" -- a model_catalog_json target that no longer exists makes Codex fail on a missing file. Routing markers now read "# Auto-injected by opencodex (undo: ocx restore)". Every ownership predicate matches OCX_SECTION_MARKER as a substring rather than by equality, so both the new line and markers written by earlier builds are still recognized, stripped and restored. An in-place rewrite refreshes the marker, so an existing install gains the hint on its next start instead of keeping a bare marker. Scope is routing only. Prompt layers keep the bare marker, because "ocx restore" is not what undoes them. * fix(cli): point a dead-proxy status report at the offline Codex restore When the proxy is down, "ocx status" says Codex requests will fail and then offers only ways to bring the proxy back: restart it, install the service, repair the service. For the user in #5261 that was the wrong half of the choice. Their injected routing points Codex's own built-in openai provider at a dead loopback port, so they were stopped at Codex sign-in, and every suggestion on screen asked them to fix opencodex first. Add the other half. When the proxy is down and the routing is one opencodex owns, the report now says that sign-in fails too, and names "ocx restore", which needs no proxy, no management API and no network. The sentences live in a pure function beside unusedProxyWarningLines so they are testable without spawning the CLI. Routing opencodex does not own is excluded: "ocx restore" would not remove somebody else's local gateway, so advertising it there would be a false promise. * test(codex): pin the Codex sign-in lockout behind a stopped proxy Covers the state #5261 was actually reported in: routing on disk, proxy gone, nothing listening on the loopback port Codex is pointed at. The tests never start a proxy or bind a port, because recovery has to work without one, and a test that needed a live proxy would be exercising the wrong state. What it holds: - Routing written before the recovery hint existed is still recognized, and recovery from the old and new marker forms is byte-identical, so an install that upgrades mid-incident restores the same way. - Removal clears the dead base URL, the realtime sideband override and the catalog pointer together, while leaving the user's own keys. The catalog pointer matters as much as the routing: left behind, it names a file only opencodex maintains and Codex fails on a missing target. - An install that predates the hint gains it in place on the next injection, without adding an ownership line or breaking idempotency. - A user-owned root override is still untouched and gains no hint. - The dead-proxy advice appears only for routing opencodex owns. - Both recovery surfaces name a command the CLI registry actually has. That one is derived from the marker rather than restated, so renaming the command in one place fails here instead of shipping a config file that points at nothing. * docs: add a troubleshooting page for a Codex lockout behind a stopped proxy There was no page for the state in #5261, and it is the one a user in it can actually reach: Codex is unusable, so the docs site and the config file are what is left. The page names the mechanism, both ways out, and the manual edit for someone without the CLI. It warns specifically against deleting the catalog pointer on its own, which is the repair people reach for and which produces the same symptom from a second cause. Account-pool failures are covered separately on the same page rather than folded into the lockout. They happened in the same session in the report, but the pool needs a live management API and a fixed loopback callback port, so they are a different problem with a different fix. * docs(devlog): record lane H, the Codex sign-in lockout response Names the mechanism, the four independent source reads that agreed on it, and the three gaps this lane deliberately leaves open. Also records that the account-pool failures which opened the report are a second cause with a different fix, so a later reader does not merge them. * fix(codex): correct the marker refresh fallout and the manual removal steps Three existing cases asserted that injecting over routing we already own returns the file byte for byte apart from the URL. Refreshing the ownership marker breaks that literal expectation, and hosted CI failed on exactly those three. The contract they protect still holds -- injection is idempotent and no unrelated value moves -- so they now expect our own marker to refresh and assert everything else unchanged, including the malformed tail that must be returned verbatim. The troubleshooting page told a stuck user to delete every "# Auto-injected by opencodex" comment and the line below it. That same comment sits above other managed keys, such as an injected developer_instructions, so following it would have cost configuration that has nothing to do with sign-in. It now names the three keys to remove and says to go by the key rather than the comment. The lockout test claimed more migration than it exercised: the fixture carries two markers and only the routing writer had run. It now asserts the exact marker list at each step, which pins the real behaviour -- each writer refreshes only the marker it owns -- and covers the realtime override as well. Dropped one assertion that restated how the constant is defined rather than testing behaviour. * test(codex): derive marker assertions from the constants they describe Swept every marker occurrence under tests/ and classified each one as routing output, prompt-layer output, or an input fixture. The three cases hosted CI failed on are already fixed; this closes the class that produced them rather than the three instances. Assertions on what the injector WRITES above a routing key now come from OCX_ROUTING_MARKER_LINE, and the two that checked a substring now assert the whole line. A substring check passes even when the wrong ownership line is written above a routing key, which is exactly the defect that would have to be caught here. The four prompt-layer files kept a private literal copy of the bare marker. They now derive it from OCX_SECTION_MARKER and say why: prompt layers keep the short marker because "ocx restore" is not their undo, so the two scopes cannot drift apart silently. Input fixtures are deliberately left as literals. A hand-written config or one from an older build is what those tests exist to exercise, and rewriting them to the current constant would delete the backward compatibility coverage instead of strengthening it. --- .../020_lane_h_codex_signin_lockout.md | 89 ++++++++++++ docs-site/astro.config.mjs | 1 + .../docs/fr/guides/codex-integration.md | 4 +- .../content/docs/guides/codex-integration.md | 6 +- .../docs/ja/guides/codex-integration.md | 4 +- .../docs/ko/guides/codex-integration.md | 6 +- .../docs/ru/guides/codex-integration.md | 4 +- .../docs/tr/guides/codex-integration.md | 4 +- .../troubleshooting/codex-cannot-sign-in.md | 99 +++++++++++++ .../docs/zh-cn/guides/codex-integration.md | 4 +- .../docs/zh-tw/guides/codex-integration.md | 4 +- scripts/test-layout/layout.json | 1 + src/cli/index.ts | 10 +- src/cli/status.ts | 27 ++++ src/codex/inject/config-toml.ts | 18 ++- src/codex/injected-marker.ts | 18 +++ .../codex-inject-integration.test.ts | 7 +- tests/codex-integration/codex-inject.test.ts | 25 +++- .../codex-prompt-adopt.test.ts | 6 +- .../codex-prompt-layers-read.test.ts | 6 +- .../codex-prompt-layers-write.test.ts | 6 +- .../codex-prompt-layers.test.ts | 4 +- .../codex-prompt-route.test.ts | 6 +- .../codex-signin-lockout.test.ts | 131 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/service/shutdown-launcher.test.ts | 3 +- 26 files changed, 453 insertions(+), 41 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/020_lane_h_codex_signin_lockout.md create mode 100644 docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md create mode 100644 tests/codex-integration/codex-signin-lockout.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_lane_h_codex_signin_lockout.md b/devlog/_plan/260920_meaning_preservation_batch/020_lane_h_codex_signin_lockout.md new file mode 100644 index 00000000000..fb06afae186 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_lane_h_codex_signin_lockout.md @@ -0,0 +1,89 @@ +# Lane H — Codex sign-in lockout behind a stopped proxy (#5261) + +Status: OPEN. Base is `origin/dev` at `b9483b3b51`. One branch, ordered commits, one pull +request to `dev`, matching the topology in [010_phase2.md](010_phase2.md). + +This lane is not a consolidation bundle. It is incident response to a user report, and it +was scheduled ahead of the phase 2 lanes because the reported failure ends with the user +unable to sign in to Codex at all. + +## What was reported + +A Windows 11 user on 2.59.0 configured opencodex, repeatedly failed to add accounts to the +pool, then found Codex could no longer call models. After a restart Codex would not sign in, +showing only a retry. Three screenshots on the issue show the cause on their machine: the +root override in `~/.codex/config.toml` pointing at `http://127.0.0.1:10100/v1` with the +proxy process gone, and a `model_catalog_json` naming a catalog file that no longer existed. + +## What the source says + +Four independent reads of `dev` agreed on the following. No proxy was started and no config +was touched to establish any of it. + +1. The default loopback injection does not add a provider. It sets the codex-rs root key + `openai_base_url`, which redirects Codex's own built-in `openai` provider + (`src/codex/inject.ts:102`), plus `experimental_realtime_ws_base_url` and + `model_catalog_json`. It is written with an atomic replace into `$CODEX_HOME/config.toml` + (`src/codex/inject.ts:591`), so it survives a reboot. +2. No auth, token or sign-in endpoint is separately redirected at the proxy. The redirect is + the built-in provider's base URL, and Codex has no second endpoint to fall back to. +3. There is no liveness precondition on the write (`src/codex/inject.ts:190`) and no + fail-open path back to the real upstream anywhere in the runtime. +4. Applying the integration does not install a service; that is a separate + `ocx service install` (`src/cli/init.ts:226`, `src/cli/init.ts:255`). The Windows + scheduled task carries a logon trigger and no boot trigger + (`src/service/windows-taskxml.ts:225`). Injection present with nothing listening is + therefore an ordinary post-reboot state, not a corruption. +5. The shim runs `ocx ensure` with output discarded and `|| true`, then launches the real + Codex regardless (`src/codex/shim-templates.ts:134`), so a failed auto-start is silent. + It is also CLI-only (`src/codex/autostart-health.ts`), so it never covered the reporter, + who was in the Codex app. +6. Recovery already existed and already worked offline: `ocx restore` needs no proxy, no + management API and no network (`src/cli/dispatch.ts:198`). It was simply not discoverable. + `ocx status` on a dead proxy offered only ways to restart it (`src/cli/index.ts:1586`), + the injected config named no command, and no troubleshooting page covered the state. + +## The account-pool failures are a second cause + +They began the session but are not the lockout. The pool is served by the management API, so +both `ocx account login openai` and the dashboard roster need a live proxy +(`src/cli/runtime-api.ts:68`). The browser flow additionally needs the fixed callback port +1455, which cannot move (`src/oauth/callback-server.ts:137`), and the Windows browser launch +swallows its own failure (`src/lib/open-url.ts:20`). The dashboard keeps the last good rows +after a failed refresh (`gui/src/hooks/useCodexAccountPool.ts:325`), which is why a new +account can be absent while older ones still show. Documented, not changed, in this lane. + +## What this lane changes + +The direction taken is the second of the two the incident allows. Excluding sign-in from the +proxy path is not expressible: `openai_base_url` is one key for one built-in provider, and +when the proxy is down no scoping helps. So the failure is made detectable and the recovery +discoverable. + +1. Routing markers name their own undo: `# Auto-injected by opencodex (undo: ocx restore)`. + Ownership is matched as a substring everywhere, so older markers keep working, and an + in-place rewrite refreshes the line so existing installs gain it on the next start. +2. `ocx status` on a dead proxy over routing we own now says sign-in fails too, and names + the command that does not need the proxy back. +3. A troubleshooting page for the state, including the manual edit and the warning against + dropping the catalog pointer alone. +4. Regression tests that reconstruct the reported config and run recovery with nothing + listening. + +## What it does not close + +- The shim still discards `ocx ensure` failures, and remains CLI-only, so the Codex app is + still not covered by auto-start at all. +- Nothing revalidates `model_catalog_json` after injection. The inject-time chooser refuses a + missing owned catalog (`src/codex/inject/config-toml.ts:597`), but a file removed later + leaves a pointer that makes Codex fail to load its config. +- Windows autostart has no boot trigger, so the post-reboot gap is unchanged. + +Each is a separate change with its own risk, and none of them is what locks the user out on +its own. The issue stays open for them. + +## Verification + +Static source review and exact-head hosted CI only. No local suite, typecheck, build, install, +service action or `ocx` invocation was used to establish any claim above, because the incident +itself is a configuration change that locked a user out. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 29b5abb1e79..e6950ee1851 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -164,6 +164,7 @@ export default defineConfig({ items: [ { label: "Windows Memory Growth", translations: { fr: "Augmentation de la mémoire sous Windows", ko: "Windows 메모리 증가", "zh-CN": "Windows 内存增长", "zh-TW": "Windows 記憶體增長", ru: "Рост памяти в Windows", ja: "Windows メモリ増加", tr: "Windows Bellek Artışı" }, slug: "troubleshooting/windows-memory" }, { label: "Disk Usage from Temp Files", translations: { fr: "Espace disque et fichiers temporaires", ko: "임시 파일 디스크 사용량", "zh-CN": "临时文件磁盘占用", "zh-TW": "暫存檔磁碟用量", ru: "Использование диска временными файлами", ja: "一時ファイルのディスク使用量", tr: "Geçici Dosya Disk Kullanımı" }, slug: "troubleshooting/disk-usage-temp-files" }, + { label: "Codex Cannot Sign In or Load", translations: { fr: "Codex ne peut pas se connecter", ko: "Codex 로그인 불가", "zh-CN": "Codex 无法登录", "zh-TW": "Codex 無法登入", ru: "Codex не может войти", ja: "Codex にサインインできない", tr: "Codex Oturum Açamıyor" }, slug: "troubleshooting/codex-cannot-sign-in" }, ], }, { label: "Contributing", translations: { fr: "Contribuer", ko: "기여하기", "zh-CN": "贡献", "zh-TW": "貢獻", ru: "Как внести вклад", ja: "コントリビュート", tr: "Katkıda Bulunma" }, slug: "contributing" }, diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index dd1d5f97dd6..b4467db2701 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -21,7 +21,7 @@ l'identifiant du fournisseur `openai` intégré à Codex et fait pointer ce four ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # only when fastMode is set; unset adds no [features] table @@ -119,7 +119,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 66e28cb9ab2..5d2b9f3a266 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -35,9 +35,9 @@ Codex's built-in `openai` provider id and points that provider at opencodex: ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1" # only when fastMode is set; unset adds no [features] table @@ -299,7 +299,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 56c69f388e7..31134ea6a38 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -14,7 +14,7 @@ opencodex は、Codex が読み取る 2 つの内容 (構成 (`$CODEX_HOME/confi ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # fastMode を設定した場合のみ。未設定なら [features] は作られません @@ -82,7 +82,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index fa172286f18..72fef21fea1 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -20,9 +20,9 @@ Pool 모드에서는 선택된 저장 계정이 쿨다운 중이고 사용 가 ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1" # fastMode를 설정했을 때만 들어갑니다. 설정하지 않으면 [features] 자체가 생기지 않습니다 @@ -169,7 +169,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 4f318434807..5e1a62ec71b 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -21,7 +21,7 @@ opencodex заставляет Codex маршрутизировать запро ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # только если fastMode задан; без него таблица [features] не создаётся @@ -122,7 +122,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index e1ce40af776..4f7b49328ac 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -25,7 +25,7 @@ ve bu sağlayıcıyı opencodex'e yönlendirir: ```toml # kök anahtarlar, ilk tablodan önce model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # yalnızca fastMode ayarlandığında; ayarlanmadığında [features] tablosu eklenmez @@ -134,7 +134,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # dosyanın sonuna eklenir -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" diff --git a/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md b/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md new file mode 100644 index 00000000000..a5971443b34 --- /dev/null +++ b/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md @@ -0,0 +1,99 @@ +--- +title: Codex Cannot Sign In or Load +description: What to do when Codex fails at sign-in or every request errors after opencodex was applied, and how to hand Codex back to its own account without starting the proxy. +--- + +If Codex stops at a sign-in screen, reports that it cannot load sign-in +requirements, or fails every model request after you set up opencodex, the most +likely cause is that Codex is still pointed at the opencodex proxy while the +proxy is not running. This was reported as +[#5261](https://github.com/lidge-jun/opencodex/issues/5261). + +## Why this happens + +On the default loopback setup, opencodex does not give Codex a separate +provider. It points Codex's own built-in `openai` provider at the proxy, by +writing a root override into `$CODEX_HOME/config.toml` (`%USERPROFILE%\.codex` +on Windows): + +```toml +model_catalog_json = "/absolute/path/to/opencodex-catalog.json" +# Auto-injected by opencodex (undo: ocx restore) +openai_base_url = "http://127.0.0.1:10100/v1" +# Auto-injected by opencodex (undo: ocx restore) +experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1" +``` + +Those lines are on disk, so they survive a reboot. If the proxy is not running +when Codex starts, that address answers nothing, and Codex has no second +endpoint to fall back to. The screen you get says nothing about opencodex, +which is why the state is easy to misread as a Codex problem. + +The proxy can be absent for ordinary reasons. Applying the Codex integration +does not install a background service — that is a separate `ocx service install` +step — so after a restart there may be nothing to bring the proxy back. A +registered Windows scheduled task starts at logon rather than at boot, and it +can also be disabled, fail to launch, or lose the port to another process. + +## Get Codex working again + +Pick whichever outcome you want. Both are safe to run while the proxy is down. + +**Hand Codex back to its own account and endpoints:** + +```bash +ocx restore +``` + +This removes the injected routing, the realtime override and the opencodex +catalog pointer, and needs no running proxy, no dashboard session and no +network. Codex signs in and runs normally afterwards. When you want opencodex +back, `ocx restore back` re-points Codex at the proxy. + +**Or bring the proxy back instead:** + +```bash +ocx start +ocx service install # keep it running across restarts +``` + +`ocx status` reports whether the proxy is answering and whether Codex is +currently routed through it. `ocx doctor` explains the same state in more +detail and names the repair it recommends. + +## If ocx is not available + +You can undo the routing by hand. Open `$CODEX_HOME/config.toml` and delete +three things: the `openai_base_url` line, the +`experimental_realtime_ws_base_url` line, and any `model_catalog_json` line +ending in `opencodex-catalog.json`. Remove the +`# Auto-injected by opencodex` comment sitting directly above each of the first +two along with them. + +Go by the key name, not by the comment. opencodex uses the same ownership +comment above other keys it manages, such as an injected +`developer_instructions`, and deleting those will not help you sign in while +costing you configuration you may want back. + +Delete the `model_catalog_json` line **with** the routing, not on its own. A +`model_catalog_json` that names a file which no longer exists makes Codex fail +to load its configuration at all, which looks like the same lockout for a +different reason. + +## Accounts that would not add or display + +Failures adding accounts to the pool, or added accounts not appearing, are a +separate matter from the lockout above, even when they happen in the same +session. The account pool is served by the proxy's management API, so both the +`ocx account login openai` flow and the dashboard list need a running proxy +before anything else can work. The browser sign-in also returns to +`http://localhost:1455/auth/callback`, a fixed address that cannot move to +another port. If something else holds port 1455, or a browser cannot be +launched, use the device flow instead: + +```bash +ocx account login openai --device +``` + +See [Codex Integration](/guides/codex-integration/) for what the injection +writes and how routing is chosen. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 4643248dfe3..982a734304b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -20,7 +20,7 @@ Codex 内置的 `openai` provider id,并将该 provider 指向 opencodex: ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # 仅在设置了 fastMode 时写入;未设置则不会创建 [features] 表 @@ -110,7 +110,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 274ae984599..c0ca280658c 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -19,7 +19,7 @@ bearer。這些路徑不會彼此 fallback。shipped v1 設定會遷移到 marke ```toml # 根級鍵,必須位於第一個 table 之前 model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # 僅在設定 fastMode 時寫入;未設定時不新增 [features] table @@ -107,7 +107,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # 追加到檔案末尾 -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7522f69ddae..1a31307d1e6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -562,6 +562,7 @@ "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", + "codex-signin-lockout.test.ts": "codex-integration", "codex-spark-visibility.test.ts": "codex-integration", "codex-sqlite-home.test.ts": "codex-integration", "codex-sync-api.test.ts": "codex-integration", diff --git a/src/cli/index.ts b/src/cli/index.ts index 00d89469fd3..e689bd26f36 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -63,7 +63,7 @@ import { pendingTeardownsAreExactly, quarantinePendingTeardown, } from "../config/pending-teardown"; -import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; +import { collectStatus, deadProxyRoutingAdviceLines, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; import { parseStartOptions, StartArgsError } from "./start-args"; @@ -1586,6 +1586,14 @@ async function handleStatus() { console.log(installed ? " Restart with 'ocx start', or refresh the installed service: 'ocx service repair'." : " Restart with 'ocx start', or install the persistent service: 'ocx service install'."); + // Restarting is only half the choice. A user who cannot sign in to Codex at all needs the + // way out that does not require this proxy to come back (#5261). + for (const line of deadProxyRoutingAdviceLines({ + proxyUp: false, + routingKind: status.json.startup.routingKind, + })) { + console.log(` ${line}`); + } } console.log(` Dashboard: ${status.json.dashboard.url}${local}`); console.log(` Config: ${status.json.paths.config}${local}`); diff --git a/src/cli/status.ts b/src/cli/status.ts index b77f494082c..22d1e25871c 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -502,6 +502,33 @@ export function unusedProxyWarningLines(input: { ]; } +/** + * The mirror case: routing is ours and nothing is answering it. + * + * #5261: this state does not merely fail model calls. The root `openai_base_url` we inject + * is the base URL of Codex's own built-in openai provider, so with the proxy down a user can + * be stopped at Codex sign-in with no mention of opencodex anywhere on the screen. The + * injection is on disk and survives reboot, so it does not clear itself. + * + * The rest of the not-running report offers only ways to bring the proxy BACK, which is the + * wrong half of the choice for someone who wants their editor working again now. `ocx restore` + * needs no proxy, no management API and no network, so name it here — this report is the + * surface such a user is most likely to reach before the config file itself. + * + * Restricted to routing opencodex owns. `custom-local` is somebody else's gateway, and + * `ocx restore` would not remove it. + */ +export function deadProxyRoutingAdviceLines(input: { + proxyUp: boolean; + routingKind: StartupHealth["routingKind"]; +}): string[] { + if (input.proxyUp || input.routingKind !== "opencodex-local") return []; + return [ + "Codex is still pointed at this proxy, so sign-in and model requests both fail while it is down.", + "To hand Codex back to its own account and endpoints without starting anything: ocx restore", + ]; +} + export async function collectStatus(): Promise { const configDiagnostics = readConfigDiagnostics(); const config = configDiagnostics.config; diff --git a/src/codex/inject/config-toml.ts b/src/codex/inject/config-toml.ts index 23a223d221d..7577e2c6813 100644 --- a/src/codex/inject/config-toml.ts +++ b/src/codex/inject/config-toml.ts @@ -4,6 +4,7 @@ import { contextCompatibleBaseLine } from "../context-compat"; import { resolveEffectiveProjectModelProvider } from "../project-config-warnings"; import { OCX_SECTION_MARKER, + OCX_ROUTING_MARKER_LINE, REALTIME_WS_BASE_URL_KEY, isRootOpenaiBaseUrlLine, isRootRealtimeWsBaseUrlLine, @@ -128,7 +129,7 @@ export function buildProviderTableBlockForTarget( ): string { const lines = [ "", - OCX_SECTION_MARKER, + OCX_ROUTING_MARKER_LINE, "[model_providers.opencodex]", `name = ${tomlString(resolveCodexProviderDisplayName(displayName))}`, `base_url = ${tomlString(target.baseUrl)}`, @@ -213,6 +214,9 @@ export function setRootOpenaiBaseUrl( if (!isRootOpenaiBaseUrlLine(lines[i])) continue; const markerOwned = i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER); if (!markerOwned) return { content, keptUserBaseUrl: true }; + // Refresh the marker too, so a config injected by a build that predates the recovery + // hint gains it on the next `ocx start` instead of keeping a bare marker forever. + lines[i - 1] = OCX_ROUTING_MARKER_LINE; lines[i] = key; return { content: lines.join("\n"), keptUserBaseUrl: false }; } @@ -222,7 +226,7 @@ export function setRootOpenaiBaseUrl( content: content.replace(/\n+$/, "") + "\n" + - OCX_SECTION_MARKER + + OCX_ROUTING_MARKER_LINE + "\n" + key + "\n", @@ -231,7 +235,7 @@ export function setRootOpenaiBaseUrl( } let insertAt = firstTable; while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, OCX_SECTION_MARKER, key); + lines.splice(insertAt, 0, OCX_ROUTING_MARKER_LINE, key); return { content: lines.join("\n"), keptUserBaseUrl: false }; } @@ -247,18 +251,19 @@ export function setRootOpenaiBaseUrlForTarget( if (!isRootOpenaiBaseUrlLine(lines[index])) continue; const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); if (!markerOwned) return { content, keptUserBaseUrl: true }; + lines[index - 1] = OCX_ROUTING_MARKER_LINE; lines[index] = key; return { content: lines.join("\n"), keptUserBaseUrl: false }; } if (firstTable === -1) { return { - content: `${content.replace(/\n+$/, "")}\n${OCX_SECTION_MARKER}\n${key}\n`, + content: `${content.replace(/\n+$/, "")}\n${OCX_ROUTING_MARKER_LINE}\n${key}\n`, keptUserBaseUrl: false, }; } let insertAt = firstTable; while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt -= 1; - lines.splice(insertAt, 0, OCX_SECTION_MARKER, key); + lines.splice(insertAt, 0, OCX_ROUTING_MARKER_LINE, key); return { content: lines.join("\n"), keptUserBaseUrl: false }; } @@ -284,13 +289,14 @@ export function setRootRealtimeWsBaseUrl( if (!isRootRealtimeWsBaseUrlLine(lines[index])) continue; const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); if (!markerOwned) return { content, keptUserRealtimeWsBaseUrl: true }; + lines[index - 1] = OCX_ROUTING_MARKER_LINE; lines[index] = key; return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false }; } for (let index = 0; index < rootEnd; index += 1) { if (!isRootOpenaiBaseUrlLine(lines[index])) continue; if (!(index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER))) continue; - lines.splice(index + 1, 0, OCX_SECTION_MARKER, key); + lines.splice(index + 1, 0, OCX_ROUTING_MARKER_LINE, key); return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false }; } // No marker-owned routing override to attach to: the override has no owner, so inject nothing. diff --git a/src/codex/injected-marker.ts b/src/codex/injected-marker.ts index 08033176488..bf27f8185aa 100644 --- a/src/codex/injected-marker.ts +++ b/src/codex/injected-marker.ts @@ -11,6 +11,24 @@ import { parseTomlString } from "./paths"; export const OCX_SECTION_MARKER = "# Auto-injected by opencodex"; +/** + * The marker line actually written above ROUTING keys, carrying the command that undoes them. + * + * #5261: a Windows user whose proxy had stopped was locked out of Codex sign-in, because the + * root `openai_base_url` we write keeps pointing Codex's built-in openai provider at a port + * nothing is listening on. The only surface such a user can still read is `config.toml` itself, + * and it said nothing but "Auto-injected by opencodex" — so the recovery they found was to + * hand-delete lines and the catalog file, which is strictly worse than `ocx restore`. + * + * Every ownership predicate below matches on {@link OCX_SECTION_MARKER} as a SUBSTRING, never by + * equality, so this longer line is recognized by them and by any opencodex old enough to predate + * it. That is the whole reason the hint is appended to the marker instead of occupying a line of + * its own: a separate comment line would survive removal as orphaned text. + * + * Scope is routing only. Prompt layers keep the bare marker: `ocx restore` is not their undo. + */ +export const OCX_ROUTING_MARKER_LINE = `${OCX_SECTION_MARKER} (undo: ocx restore)`; + export function isRootOpenaiBaseUrlLine(line: string): boolean { return /^\s*openai_base_url\s*=/.test(line); } diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 75fc7b8217e..e88b4446d4d 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -9,6 +9,7 @@ import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER, } from "../../src/codex/subagent-defaults"; +import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -74,9 +75,9 @@ function runRestore(codexHome: string, ocxHome: string, asyncRestore = false): { describe("injectCodexConfig integration (Design B)", () => { const DESIGN_B_BLOCK = [ - "# Auto-injected by opencodex", + OCX_ROUTING_MARKER_LINE, 'openai_base_url = "http://127.0.0.1:10100/v1"', - "# Auto-injected by opencodex", + OCX_ROUTING_MARKER_LINE, 'experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1"', ].join("\n"); let codexHome: string; @@ -909,7 +910,7 @@ describe("injectCodexConfig integration (Design B)", () => { const config = readFileSync(join(codexHome, "config.toml"), "utf8"); expect(config).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); - expect(config).toContain("# Auto-injected by opencodex"); + expect(config).toContain(OCX_ROUTING_MARKER_LINE); expect(config).toContain("[model_providers.opencodex]"); expect(config).not.toContain('model_provider = "opencodex"'); expect(config).toContain('model = "gpt-5.5"'); diff --git a/tests/codex-integration/codex-inject.test.ts b/tests/codex-integration/codex-inject.test.ts index 53762f132fd..d1763153ab9 100644 --- a/tests/codex-integration/codex-inject.test.ts +++ b/tests/codex-integration/codex-inject.test.ts @@ -22,7 +22,7 @@ import { resolveCodexProviderDisplayName, } from "../../src/codex/inject/config-toml"; import { extractOcxProviderTableBlock } from "../../src/codex/inject/remove"; -import { OCX_SECTION_MARKER, stripJournaledOpenaiBaseUrl } from "../../src/codex/injected-marker"; +import { OCX_ROUTING_MARKER_LINE, OCX_SECTION_MARKER, stripJournaledOpenaiBaseUrl } from "../../src/codex/injected-marker"; import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER, @@ -469,7 +469,7 @@ describe("Design B openai_base_url injection", () => { expect(keptUserBaseUrl).toBe(false); const lines = content.split("\n"); - const markerIdx = lines.findIndex(l => l.includes("Auto-injected by opencodex")); + const markerIdx = lines.findIndex(l => l.includes(OCX_SECTION_MARKER)); const keyIdx = lines.findIndex(l => l.startsWith("openai_base_url")); const tableIdx = lines.findIndex(l => l.trim() === "[features]"); expect(markerIdx).toBeGreaterThanOrEqual(0); @@ -521,8 +521,10 @@ describe("Design B openai_base_url injection", () => { const lines = content.split("\n"); const routing = lines.indexOf('openai_base_url = "http://127.0.0.1:10100/v1"'); expect(routing).toBeGreaterThan(0); - expect(lines[routing - 1]).toContain("Auto-injected by opencodex"); - expect(lines[routing + 1]).toContain("Auto-injected by opencodex"); + // Asserted as the whole routing marker, not a substring of it: a substring check passes + // even when the wrong ownership line is written above a routing key (#5261). + expect(lines[routing - 1]).toBe(OCX_ROUTING_MARKER_LINE); + expect(lines[routing + 1]).toBe(OCX_ROUTING_MARKER_LINE); expect(lines[routing + 2]).toBe('experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1"'); expect(lines.indexOf("[features]")).toBeGreaterThan(routing + 2); expect(content.match(/Auto-injected by opencodex/g)?.length).toBe(2); @@ -852,12 +854,19 @@ describe("EOL boundary helpers (Windows CRLF configs)", () => { }); }); +/** + * What an injection does to a marker it already owns: the routing keys carry the recovery + * command (#5261), and a rewrite in place refreshes a bare marker left by an earlier build. + * Only our own ownership line moves; everything else below is asserted unchanged. + */ +const refreshed = (content: string) => content.replace(OCX_SECTION_MARKER, OCX_ROUTING_MARKER_LINE); + test('managed injection is idempotent and retains every unrelated value',()=>{ const source=`model = "gpt-6-astra"\n${OCX_SECTION_MARKER}\nopenai_base_url = "http://127.0.0.1:10100/v1"\nservice_tier = "fast"\n[features]\ncontext_management.experimental_mode = true\n[features.multi_agent_v2]\nenabled = true\n`; const target={baseUrl:'http://127.0.0.1:10100/v1',requiresAdmissionToken:false,tokenEnv:'OPENCODEX_API_AUTH_TOKEN' as const}; const result=setRootOpenaiBaseUrl(source,target); expect(result.keptUserBaseUrl).toBe(false); - expect(result.content).toBe(source.replace('10100/v1','10100/backend-api/codex')); + expect(result.content).toBe(refreshed(source).replace('10100/v1','10100/backend-api/codex')); expect(setRootOpenaiBaseUrl(result.content,target).content).toBe(result.content); expect(buildRealtimeWsBaseUrlLine(target)).toContain('10100/v1'); expect(setRootOpenaiBaseUrl(source,10100).content).toBe(result.content); @@ -866,7 +875,7 @@ test('feature disabled and user-owned routing remain intact',()=>{ const source='openai_base_url = "http://127.0.0.1:10100/v1"\n[features]\ncontext_management.experimental_mode = true\n'; expect(setRootOpenaiBaseUrl(source,10100)).toEqual({content:source,keptUserBaseUrl:true}); const managed=`${OCX_SECTION_MARKER}\nopenai_base_url = "http://127.0.0.1:10100/v1"\n[features]\ncontext_management.experimental_mode = false\n`; - expect(setRootOpenaiBaseUrl(managed,10100).content).toBe(managed); + expect(setRootOpenaiBaseUrl(managed,10100).content).toBe(refreshed(managed)); }); @@ -877,7 +886,9 @@ test("malformed TOML preserves user routing and does not enable context injectio const managed = `${OCX_SECTION_MARKER}\nopenai_base_url = "http://127.0.0.1:10100/v1"\n${malformed}\n`; for (const inject of [(source: string) => setRootOpenaiBaseUrl(source, 10100), (source: string) => setRootOpenaiBaseUrl(source, target)]) { expect(inject(userOwned)).toEqual({ content: userOwned, keptUserBaseUrl: true }); - expect(inject(managed)).toEqual({ content: managed, keptUserBaseUrl: false }); + // A file we cannot parse is still not rewritten beyond the routing we own: the marker + // refreshes, the malformed tail is returned byte for byte. + expect(inject(managed)).toEqual({ content: refreshed(managed), keptUserBaseUrl: false }); } } }); diff --git a/tests/codex-integration/codex-prompt-adopt.test.ts b/tests/codex-integration/codex-prompt-adopt.test.ts index 7bffd6dedce..16ec5cd637d 100644 --- a/tests/codex-integration/codex-prompt-adopt.test.ts +++ b/tests/codex-integration/codex-prompt-adopt.test.ts @@ -18,8 +18,12 @@ import { salvageProjection, } from "../../src/codex/prompt-layers"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { OCX_SECTION_MARKER } from "../../src/codex/injected-marker"; -const MARKER = "# Auto-injected by opencodex"; +// Prompt layers deliberately keep the BARE ownership marker: 'ocx restore' is not their undo, +// so the recovery hint that routing keys carry (#5261) does not belong here. Derived from the +// constant rather than restated, so the two scopes cannot drift apart silently. +const MARKER = OCX_SECTION_MARKER; const roots: string[] = []; function fixture(config: string, store?: string) { diff --git a/tests/codex-integration/codex-prompt-layers-read.test.ts b/tests/codex-integration/codex-prompt-layers-read.test.ts index 7d74628d494..97b18f5a65a 100644 --- a/tests/codex-integration/codex-prompt-layers-read.test.ts +++ b/tests/codex-integration/codex-prompt-layers-read.test.ts @@ -15,8 +15,12 @@ import { readPromptLayers, } from "../../src/codex/prompt-layers"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { OCX_SECTION_MARKER } from "../../src/codex/injected-marker"; -const MARKER = "# Auto-injected by opencodex"; +// Prompt layers deliberately keep the BARE ownership marker: 'ocx restore' is not their undo, +// so the recovery hint that routing keys carry (#5261) does not belong here. Derived from the +// constant rather than restated, so the two scopes cannot drift apart silently. +const MARKER = OCX_SECTION_MARKER; const roots: string[] = []; function fixture(config: string | null, store?: string | null): { configPath: string; storePath: string } { diff --git a/tests/codex-integration/codex-prompt-layers-write.test.ts b/tests/codex-integration/codex-prompt-layers-write.test.ts index 09c7d6da093..52c35bce8e1 100644 --- a/tests/codex-integration/codex-prompt-layers-write.test.ts +++ b/tests/codex-integration/codex-prompt-layers-write.test.ts @@ -19,8 +19,12 @@ import { type JournalRecord, } from "../../src/codex/prompt-journal"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { OCX_SECTION_MARKER } from "../../src/codex/injected-marker"; -const MARKER = "# Auto-injected by opencodex"; +// Prompt layers deliberately keep the BARE ownership marker: 'ocx restore' is not their undo, +// so the recovery hint that routing keys carry (#5261) does not belong here. Derived from the +// constant rather than restated, so the two scopes cannot drift apart silently. +const MARKER = OCX_SECTION_MARKER; const roots: string[] = []; function fixture(config?: string, store?: string) { diff --git a/tests/codex-integration/codex-prompt-layers.test.ts b/tests/codex-integration/codex-prompt-layers.test.ts index 7a289c92013..5aec0f1f911 100644 --- a/tests/codex-integration/codex-prompt-layers.test.ts +++ b/tests/codex-integration/codex-prompt-layers.test.ts @@ -13,6 +13,7 @@ import { repoPath } from "../helpers/repo-root"; import * as encoding from "../../src/codex/prompt-layers/encoding"; import * as revision from "../../src/codex/prompt-layers/revision"; import * as tomlRead from "../../src/codex/prompt-layers/toml-read"; +import { OCX_SECTION_MARKER } from "../../src/codex/injected-marker"; import { LAYER_INVENTORY, TOGGLE_IDS, @@ -183,7 +184,8 @@ describe("revision", () => { }); test("changes when only the marker is removed", () => { - const withMarker = '# Auto-injected by opencodex\ndeveloper_instructions = "x"\n'; + // Prompt layers keep the bare marker; the routing hint (#5261) is a different scope. + const withMarker = `${OCX_SECTION_MARKER}\ndeveloper_instructions = "x"\n`; const without = 'developer_instructions = "x"\n'; expect(computeRevision(withMarker, "{}")).not.toBe(computeRevision(without, "{}")); }); diff --git a/tests/codex-integration/codex-prompt-route.test.ts b/tests/codex-integration/codex-prompt-route.test.ts index ec90891f2a5..2b6b6b0263b 100644 --- a/tests/codex-integration/codex-prompt-route.test.ts +++ b/tests/codex-integration/codex-prompt-route.test.ts @@ -21,9 +21,13 @@ import { import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { OCX_SECTION_MARKER } from "../../src/codex/injected-marker"; import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; -const MARKER = "# Auto-injected by opencodex"; +// Prompt layers deliberately keep the BARE ownership marker: 'ocx restore' is not their undo, +// so the recovery hint that routing keys carry (#5261) does not belong here. Derived from the +// constant rather than restated, so the two scopes cannot drift apart silently. +const MARKER = OCX_SECTION_MARKER; const config = { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; const roots: string[] = []; diff --git a/tests/codex-integration/codex-signin-lockout.test.ts b/tests/codex-integration/codex-signin-lockout.test.ts new file mode 100644 index 00000000000..14452235647 --- /dev/null +++ b/tests/codex-integration/codex-signin-lockout.test.ts @@ -0,0 +1,131 @@ +/** + * #5261: opencodex must not be able to lock a user out of Codex. + * + * The reported machine was a Windows 11 install whose proxy had stopped. The routing + * opencodex had written to `~/.codex/config.toml` stayed on disk across a reboot, so Codex's + * own built-in openai provider kept resolving to a loopback port nothing was listening on, + * and the user was stopped at sign-in with no mention of opencodex on screen. + * + * Nothing here starts a proxy, binds a port, or touches a real Codex home. That is the point: + * these are exactly the transforms recovery must be able to run while the proxy is dead, so a + * test that needed one running would be testing the wrong state. + */ +import { describe, expect, test } from "bun:test"; +import { + OCX_ROUTING_MARKER_LINE, + OCX_SECTION_MARKER, + hasInjectedCodexRouting, + hasInjectedOpenaiBaseUrl, +} from "../../src/codex/injected-marker"; +import { setRootOpenaiBaseUrl, setRootRealtimeWsBaseUrl } from "../../src/codex/inject/config-toml"; +import { stripOpencodexConfig } from "../../src/codex/inject/remove"; +import { deadProxyRoutingAdviceLines } from "../../src/cli/status"; +import { findCommand } from "../../src/cli/registry"; + +/** A Windows catalog path as TOML stores it: a basic string doubles the separators (#1798). */ +const WINDOWS_CATALOG = JSON.stringify(String.raw`C:\Users\example\.codex\opencodex-catalog.json`); + +/** The loopback target the reported install was on. */ +const TARGET = { + baseUrl: "http://127.0.0.1:10100/v1", + requiresAdmissionToken: false, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", +} as const; + +/** The reported file, reconstructed: routing on disk, proxy gone. `marker` is the ownership line. */ +function lockedOutConfig(marker: string): string { + return [ + 'model = "gpt-5.5"', + `model_catalog_json = ${WINDOWS_CATALOG}`, + marker, + 'openai_base_url = "http://127.0.0.1:10100/v1"', + marker, + 'experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1"', + "", + "[features]", + "fast_mode = true", + "", + ].join("\n"); +} + +describe("Codex sign-in lockout behind a stopped proxy (#5261)", () => { + test("routing written before the recovery hint existed is still recognized as ours", () => { + const legacy = lockedOutConfig(OCX_SECTION_MARKER); + expect(hasInjectedOpenaiBaseUrl(legacy)).toBe(true); + expect(hasInjectedCodexRouting(legacy)).toBe(true); + }); + + test("the hint changes what we write, never what we recognize or remove", () => { + const hinted = lockedOutConfig(OCX_ROUTING_MARKER_LINE); + expect(hasInjectedOpenaiBaseUrl(hinted)).toBe(true); + expect(hasInjectedCodexRouting(hinted)).toBe(true); + // Byte-identical recovery from either marker: the two forms must not diverge, or an + // install that upgraded mid-incident would restore differently from one that did not. + expect(stripOpencodexConfig(hinted)).toBe(stripOpencodexConfig(lockedOutConfig(OCX_SECTION_MARKER))); + }); + + test("recovery removes every dead endpoint without a proxy, and keeps the user's own keys", () => { + const restored = stripOpencodexConfig(lockedOutConfig(OCX_ROUTING_MARKER_LINE)); + expect(restored).not.toContain("openai_base_url"); + expect(restored).not.toContain("experimental_realtime_ws_base_url"); + // The catalog pointer has to go with the routing. Left behind, it names a file only + // opencodex maintains, and Codex fails on a missing model_catalog_json target. + expect(restored).not.toContain("opencodex-catalog.json"); + expect(restored).not.toContain("Auto-injected by opencodex"); + expect(restored).toContain('model = "gpt-5.5"'); + expect(restored).toContain("[features]"); + expect(restored).toContain("fast_mode = true"); + }); + + test("an install that predates the hint gains it on the next injection, and stays idempotent", () => { + const legacy = lockedOutConfig(OCX_SECTION_MARKER); + const markers = (content: string) => content.split("\n").filter(line => line.includes(OCX_SECTION_MARKER)); + expect(markers(legacy)).toEqual([OCX_SECTION_MARKER, OCX_SECTION_MARKER]); + + const first = setRootOpenaiBaseUrl(legacy, 10100); + expect(first.keptUserBaseUrl).toBe(false); + // Each writer refreshes only the marker it owns, so after the routing key alone the + // realtime marker is still the legacy line. Refreshed in place, never appended. + expect(markers(first.content)).toEqual([OCX_ROUTING_MARKER_LINE, OCX_SECTION_MARKER]); + expect(setRootOpenaiBaseUrl(first.content, 10100).content).toBe(first.content); + + const both = setRootRealtimeWsBaseUrl(first.content, TARGET); + expect(both.keptUserRealtimeWsBaseUrl).toBe(false); + expect(markers(both.content)).toEqual([OCX_ROUTING_MARKER_LINE, OCX_ROUTING_MARKER_LINE]); + expect(setRootRealtimeWsBaseUrl(both.content, TARGET).content).toBe(both.content); + }); + + test("a user's own root override is still left alone and gains no hint", () => { + const userOwned = 'model = "gpt-5.5"\nopenai_base_url = "https://gateway.example/v1"\n'; + const result = setRootOpenaiBaseUrl(userOwned, 10100); + expect(result.keptUserBaseUrl).toBe(true); + expect(result.content).toBe(userOwned); + expect(result.content).not.toContain("undo:"); + }); + + test("a dead proxy on our own routing is told how to get Codex back without one", () => { + const advice = deadProxyRoutingAdviceLines({ proxyUp: false, routingKind: "opencodex-local" }); + expect(advice.length).toBeGreaterThan(0); + expect(advice.join(" ")).toContain("sign-in"); + expect(advice.join(" ")).toContain("ocx restore"); + }); + + test("the advice stays silent when it would be wrong or unactionable", () => { + // A live proxy is not this failure. + expect(deadProxyRoutingAdviceLines({ proxyUp: true, routingKind: "opencodex-local" })).toEqual([]); + // Routing we do not own: `ocx restore` would not remove it, so promising it would mislead. + for (const routingKind of ["native", "custom-local", "custom-remote", "unknown"] as const) { + expect(deadProxyRoutingAdviceLines({ proxyUp: false, routingKind })).toEqual([]); + } + }); + + test("both recovery surfaces name a command the CLI actually has", () => { + // Derived from the marker rather than restated, so renaming the command in one place and + // not the other fails here instead of shipping a config file that names nothing. + const named = /ocx ([a-z][a-z-]*)/.exec(OCX_ROUTING_MARKER_LINE)?.[1]; + expect(named).toBeTruthy(); + expect(findCommand(named!)).toBeDefined(); + expect(deadProxyRoutingAdviceLines({ proxyUp: false, routingKind: "opencodex-local" }).join(" ")) + .toContain(`ocx ${named}`); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 308682ccebc..92dbb521190 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -393,6 +393,7 @@ "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", + "codex-signin-lockout.test.ts": "codex-integration", "codex-spark-visibility.test.ts": "codex-integration", "codex-sqlite-home.test.ts": "codex-integration", "codex-sync-api.test.ts": "codex-integration", diff --git a/tests/service/shutdown-launcher.test.ts b/tests/service/shutdown-launcher.test.ts index edda9ecb5b0..c8cf5b2d98b 100644 --- a/tests/service/shutdown-launcher.test.ts +++ b/tests/service/shutdown-launcher.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { claimOwnedServiceHome } from "../helpers/owned-service-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; /** * Regression: `ocx start` + Ctrl-C must NOT orphan the Bun proxy. @@ -152,7 +153,7 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => { } expect(existsSync(join(home, "ocx.pid"))).toBe(true); const injected = readFileSync(codexConfig, "utf8"); - expect(injected).toContain("# Auto-injected by opencodex"); + expect(injected).toContain(OCX_ROUTING_MARKER_LINE); expect(injected).toContain(`openai_base_url = "http://127.0.0.1:${port}/v1"`); expect(injected).not.toContain("model_providers.opencodex"); From 41b66be6d880625055adb319aef9bce75300a42b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 16:31:22 +0900 Subject: [PATCH 09/87] fix: preserve caller constraints on the request path (#5211, #5210, #5212, #5213) (#5271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): carry allowed_tools and a caller's parallel_tool_calls to the wire Two ways a Chat Completions caller restricts tool use reached the parser and were then dropped on the way out, both under a normal HTTP 200. A tool_choice of type allowed_tools is a record, is not type "function", and carries no "function" member, so it fell past every branch of the Chat inbound translator and body.tool_choice was never assigned. The upstream received the full catalogue and no choice at all. Chat nests the subset under allowed_tools and names each entry under a member keyed by its own type, while the Responses shape mapToolChoice reads carries mode and tools on the choice itself with a flat name, so neither level lined up. Flatten both. An entry nobody can name is refused rather than skipped, because dropping one widens the very subset the field was sent to narrow. parallel_tool_calls had three provider states and two branches, in two places. When a provider expresses no preference, which is the default for every provider that never configured the knob, neither branch ran and an explicit request-level false was lost — on the translated path and, from its own copy of the same branch, on the native Chat passthrough. That state now forwards the caller's false. An explicit true still omits the key, matching the configured opt-out, so strict OpenAI-compatible hosts never see a knob they did not have to accept before. The NVIDIA and pinParallelToolCallsFalse pins are unchanged. The decision moved into openai-chat/parallel-tool-calls.ts, which both builders now read, so the three states cannot drift between them again. openai-chat.ts is 811 lines against its 822-line cap. Regressions assert on the serialized outbound request body for both builders, since a successful tool call and a 200 response look identical with or without either constraint. Closes #5211 * fix(adapters): preserve tool declaration strict and allowed_callers Three fields a caller sets on a tool declaration were parsed, carried internally, and then dropped by the outbound adapter, so the request was dispatched as though the constraint were in force and answered normally. Messages to Messages rebuilt every tool from name, description and input_schema alone. Anthropic is the target that defines strict, the Messages inbound already kept the source intent deliberately, and the OpenAI Chat adapter already forwarded it, so Anthropic was the one destination losing it. It now emits an explicit strict: true. An unstated strict stays absent: the inbound records it as false, so a false on the wire cannot be told apart from silence and must not become an opt-out nobody asked for. allowed_callers had no carrier at all. The identifier existed once in the tree, raising a caller_mode diagnostic that only becomes a refusal when the operator has set claudeCode.compatibility. The field now rides OcxTool.allowedCallers from the Messages inbound through the Responses schema — where an undeclared key is stripped, which is why it never reached buildTools — to the Anthropic wire. The OpenAI Chat and Gemini builders have no counterpart for it, so they refuse with a 400 rather than rebuild the declaration without the fence, in the shape ollama-native and kiro already use for a tool_choice they cannot enforce. The unrestricted ["direct"] default is not treated as a restriction. Gemini expresses schema-enforced calling as functionCallingConfig.mode VALIDATED. The mode was plumbed to the wire compiler but only reachable by matching a model name, so a strict declaration arrived as an ordinary AUTO turn. It now replaces the absent-choice default. NONE, ANY and a forced-name choice are stronger constraints the caller asked for and are never overwritten. Native passthrough is unaffected on every route. Closes #5210 * fix(openai-chat): keep developer messages in their conversation position A developer message kept its slot only when the provider base URL host was exactly api.openai.com. On every other OpenAI-compatible Chat endpoint its text was appended to the system prompt and the message itself was skipped, so an instruction written to apply from the second turn onward arrived ahead of the first one and the caller got an ordinary completion either way. The two halves of a Claude Code route were working against each other because of it: #4161 established that folding in-conversation instructions into the prompt preamble is harmful and made the Claude inbound mint chronological developer items specifically to preserve timeline order, and this adapter then folded them again on every host but one. One destination already had the chronological behaviour, keyed to a model id and a registry entry, because hoisting a newly appended reminder rewrites the reusable prompt prefix. That is a property of prompt-prefix caching rather than of that destination, so it is now what every destination gets, and the model/registry test is gone. A reminder that arrives while a tool call is open is still deferred past the result, which is what keeps tool-call adjacency intact; it lands in its own slot immediately after, never at the front. This commit changes placement only. The wire role is still developer on api.openai.com and system elsewhere, and is addressed separately. Co-authored-by: Yum-wu <118118663+Yum-wu@users.noreply.github.com> * fix(openai-chat): forward the developer role instead of inferring it from the host A developer message reached the upstream as developer only when the provider base URL host was exactly api.openai.com. Everywhere else it was rewritten to system, so every OpenAI-compatible gateway was assumed not to support a standard Chat Completions role until proven otherwise — including gateways that proxy OpenAI itself — and the instruction silently lost the precedence the caller chose. The role is now forwarded as sent. A destination that genuinely rejects it sets foldDeveloperRoleToSystem, which converts the role where the message already is and never moves it, so the placement contract from the previous commit holds on both paths. That makes the conversion a recorded decision about one destination rather than an inference from its hostname, which is what the hostname test could never express. The flag is registered in the provider config schema and in the exhaustive provider field policy, which is keyed on keyof OcxProviderConfig and fails typecheck until a new key is classified. Closes #5213 * fix(inbound): carry inline document bytes through to the wires that hold them Both inbound parsers reduced an attached document to its name before any adapter ran, so no adapter could forward one even to a target that has a representation for it. The Messages inbound replaced a base64 document block with a "[document: title]" marker, and the Chat file part matched no branch of the content loop at all. The request succeeded either way, so the caller could not tell "the model read the document" from "the model was told a document existed". OcxContentPart gains a document member carrying the media type and the base64 payload. The Anthropic wire emits it as the document block the caller sent, the OpenAI Chat wire as the file part that is its direct counterpart, and Gemini as the inline_data part it already uses for images and video. Widening the union is the hazard here, so the part also carries the marker every text-only consumer already falls back to. That keeps a wire with no document representation emitting exactly what it emitted before instead of undefined or a mislabelled [video]. Six consumers needed more than the fallback and are fixed explicitly: ollama-native and the Cursor tool-result decoder would have read a nonexistent imageUrl, and Kiro, Devin, Cursor and coding-agent text serializers would have produced an empty turn. Token admission counts the encoded payload rather than the marker. The untranslated-media refusal is narrowed to match, and only where a converter actually builds the part: user content on the Chat projection, user and developer messages on the Responses one. A file in a tool output, a system message or an assistant message is still refused, because those converters flatten their content to a string and exempting them would restore the silent drop the scanner exists to prevent. The scanner and the decoder share one predicate, so a request cannot be exempted in one and reduced to a marker in the other; a ";notbase64," parameter is not a payload. A reference with no bytes — a file_id, a remote source — is unchanged in every position. Tool-result documents keep the #939 marker: the Responses tool-output vocabulary has no file block and every adapter's tool-result path flattens to text, so carrying bytes there needs a separate change. Closes #5212 * fix(adapters): refuse unrepresentable declarations by default, not per adapter Adversarial review of the whole branch found the same shape of hole in two of its fixes: a constraint the normalized request now carries still reached wires that rebuild the declaration or the message without it, and answered normally. tools[].allowed_callers was refused by the OpenAI Chat and Gemini builders because those are the two the report named. Cursor, Devin, Kiro, Command Code, Ollama and the coding-agent wires rebuild tools from name, description and schema, so a caller-restricted tool reached those upstreams unrestricted. Inline document bytes had the same problem from the other direction: admission exempted every user-content document without knowing the destination, and a wire with no carrier replaced the bytes with the marker and continued. Both are now default-deny allowlists in adapters/declaration-carrier.ts, enforced at the single guard in adapters/input-media-guard.ts that every registered adapter passes through. allowed_callers reaches the anthropic wire; document bytes reach anthropic, openai-chat and google. Adding an AdapterWire member makes the omission visible in those lists rather than at a customer's upstream, which a per-adapter opt-in could never do. The Responses passthrough stays exempt from the whole guard because it forwards the original body. The refusal no longer names the tool, which is caller-controlled and put client metadata into an error body. An allowed_tools entry whose selector kind is neither function, custom, nor a hosted type is refused rather than flattened to a bare name. Token admission counts a document's payload arithmetically instead of rebuilding a request-sized data URL to measure it. * test(document): derive the attachment marker from its source constant A restated literal is the union-defect class AGENTS.md records: the next change to the marker breaks a test for the wording rather than for the contract. Every assertion about it now reads inlineDocumentMarker, and the data URL spelling comes from inlineDocumentDataUrl. * fix(adapters): type the document scan against OcxMessage and keep a developer document's role Two defects from a final adversarial pass over the branch. The document scan took content shaped as OcxContentPart[], but context.messages is OcxMessage[] and an assistant turn carries OcxAssistantContentPart[], which is not assignable to the user-content union. It now takes OcxMessage and reads the discriminant structurally, which is all it ever needed. A developer message carrying a document reached the structured-content branch and was emitted as role user, undoing the role preservation the same adapter had just established. A developer message with images keeps the user-compatible shape it has always had on this wire; a document has no such precedent and keeps its role. * docs(devlog): record lane A meaning preservation What each of the four contracts restores, the review findings that changed the shape of the fix, the union-defect check run before push, and the one gap left open. * docs(structure): repoint the instruction-ordering links at the renamed heading Renaming the section from the OpenCode Go exception to the universal contract left five documents linking a heading anchor that no longer exists, which is what the SSOT gate is for. The link text now describes the contract rather than the destination it used to be scoped to. * test(anthropic): await the registered adapter build createRegisteredAdapter wraps openai-chat in withClinePassDeepSeekV4ToolReplayCompatibility, whose buildRequest is async, so reading .body off the returned promise parsed undefined. The refusal cases in the same file already tolerated both shapes. --------- Co-authored-by: Yum-wu <118118663+Yum-wu@users.noreply.github.com> --- .../010_lane_a.md | 100 ++++++++++ .../src/content/docs/fr/guides/claude-code.md | 2 +- .../fr/reference/configuration/providers.md | 1 + .../src/content/docs/guides/claude-code.md | 20 +- docs-site/src/content/docs/guides/pi.md | 19 +- .../src/content/docs/ja/guides/claude-code.md | 2 +- .../ja/reference/configuration/providers.md | 1 + .../src/content/docs/ko/guides/claude-code.md | 2 +- .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 1 + .../src/content/docs/ru/guides/claude-code.md | 2 +- .../ru/reference/configuration/providers.md | 1 + .../src/content/docs/tr/guides/claude-code.md | 2 +- .../tr/reference/configuration/providers.md | 1 + .../content/docs/zh-cn/guides/claude-code.md | 2 +- .../reference/configuration/providers.md | 1 + .../content/docs/zh-tw/guides/claude-code.md | 2 +- .../reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 5 + src/adapters/anthropic.ts | 16 ++ src/adapters/coding-agent/protocol.ts | 11 +- src/adapters/command-code.ts | 3 +- src/adapters/cursor/protobuf-request.ts | 9 +- src/adapters/cursor/request-builder.ts | 3 + src/adapters/declaration-carrier.ts | 45 +++++ src/adapters/devin.ts | 3 + src/adapters/google-antigravity-wire.ts | 7 +- src/adapters/google.ts | 34 +++- src/adapters/image.ts | 5 +- src/adapters/input-media-guard.ts | 30 ++- src/adapters/kiro-tool-fallback.ts | 2 +- src/adapters/kiro/usage.ts | 5 +- src/adapters/ollama-native.ts | 6 + src/adapters/openai-chat.ts | 16 +- src/adapters/openai-chat/messages.ts | 75 ++++--- .../openai-chat/parallel-tool-calls.ts | 32 +++ src/adapters/openai-chat/passthrough.ts | 13 +- src/adapters/registry.ts | 5 +- src/chat/inbound.ts | 79 +++++++- src/claude/inbound-content-options.ts | 6 + src/claude/inbound.ts | 38 +++- src/config/schema/leaf-validators.ts | 1 + src/responses/inline-document.ts | 58 ++++++ src/responses/input-media.ts | 50 ++++- src/responses/parser-content.ts | 10 +- src/responses/parser-tools.ts | 3 + src/responses/parser.ts | 4 +- src/responses/schema.ts | 3 + src/server/auth-cors.ts | 1 + src/server/responses/input-admission.ts | 10 + src/types.ts | 2 + src/types/provider.ts | 13 ++ src/types/request.ts | 30 ++- src/types/tools.ts | 19 ++ structure/adapters/registry.md | 19 +- structure/data-planes/inbound-compat.md | 12 +- structure/providers-and-adapters.md | 7 +- structure/providers/chat-compat.md | 38 ++-- structure/providers/cursor.md | 2 +- structure/runtime.md | 7 +- structure/transports/inventory.md | 5 +- .../adapter-input-media-guard.test.ts | 47 ++++- ...ropic-tool-declaration-constraints.test.ts | 116 +++++++++++ .../google-strict-tool-validated-mode.test.ts | 69 +++++++ .../openai-chat-dangling-toolcalls.test.ts | 22 +-- .../openai-chat-developer-position.test.ts | 86 ++++++++ .../openai/openai-chat-system-order.test.ts | 58 ++++-- .../claude-integration/claude-inbound.test.ts | 7 +- .../claude-source-envelope.test.ts | 3 +- .../parallel-tool-calls-optin.test.ts | 71 ++++++- tests/fixtures/test-layout-expected.json | 5 + .../chat-inline-document-bytes.test.ts | 186 ++++++++++++++++++ .../responses/chat-media-translation.test.ts | 27 ++- .../chat-tool-choice-allowed-tools.test.ts | 116 +++++++++++ 74 files changed, 1520 insertions(+), 196 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md create mode 100644 src/adapters/declaration-carrier.ts create mode 100644 src/adapters/openai-chat/parallel-tool-calls.ts create mode 100644 src/responses/inline-document.ts create mode 100644 tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts create mode 100644 tests/adapters/google/google-strict-tool-validated-mode.test.ts create mode 100644 tests/adapters/openai/openai-chat-developer-position.test.ts create mode 100644 tests/responses/chat-inline-document-bytes.test.ts create mode 100644 tests/responses/chat-tool-choice-allowed-tools.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md b/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md new file mode 100644 index 00000000000..a0892345d86 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md @@ -0,0 +1,100 @@ +# Lane A — meaning preservation on the request path + +Status: OPEN. Branch `codex/260920-lane-a-meaning-preservation`, cut from `origin/dev` +`0613aaec17`. One branch, five ordered commits, one pull request against `dev`. + +## What each commit restores + +### #5211 — tool choice policy on the Chat Completions path + +Two constraints reached the parser and were dropped on the way out, both under a normal 200. + +A `tool_choice` of type `allowed_tools` is a record, is not `type: "function"`, and carries no +`function` member, so it fell past every branch of `toolChoiceToResponses` and `body.tool_choice` +was never assigned. Chat nests the subset under `allowed_tools` and names each entry under a +member keyed by its own type; the Responses shape `mapToolChoice` reads carries `mode` and +`tools` on the choice itself with a flat `name`. Both levels are now flattened. An entry that +cannot be named is refused rather than skipped, because skipping one widens the subset. + +`parallel_tool_calls` had three provider states and two branches, in two places. The unset state +is the default for every provider that never configured the knob, and it dropped the caller's own +explicit `false` — on the translated path and, from a second copy of the same branch, on the +native Chat passthrough. The decision now lives in `src/adapters/openai-chat/parallel-tool-calls.ts`, +which both builders read. An explicit `true` still omits the key, matching the configured opt-out. + +The passthrough half was found by adversarial review, not by the original report. + +### #5210 — tool declaration fields on the outbound adapters + +`strict` was kept deliberately by the Messages inbound and forwarded by the OpenAI Chat adapter, +and dropped by Anthropic — the target that defines it. It is now emitted when it is explicitly +`true`. An unstated `strict` stays absent, because the inbound records it as `false` and a +`false` on the wire cannot be told apart from silence. + +`allowed_callers` had no carrier at all. It now rides `OcxTool.allowedCallers` from the Messages +inbound, through the Responses tool schema — where an undeclared key is stripped, which is why it +never reached `buildTools` — to the Anthropic wire. The OpenAI Chat and Gemini builders have no +counterpart and refuse with a 400 rather than rebuild the declaration without the fence. The +unrestricted `["direct"]` default is not a restriction. + +Gemini's `functionCallingConfig.mode: "VALIDATED"` was plumbed to the wire compiler but only +reachable by matching a model name. A caller-declared strict tool now selects it in place of the +absent-choice default; `NONE`, `ANY` and a forced-name choice are never overwritten. + +### #5213 — developer message position, then role + +Delivered as two commits because they are two acceptance conditions. + +Position carries #5237 by Yum-wu with a `Co-authored-by` trailer. The upstream branch had the +right idea and a broken patch (a stray `];` and an assertion that put the deferred reminder +before the tool result), so the change was reimplemented and the attribution kept. One +destination already had chronological placement, keyed to a model id and a registry entry; that +is a property of prompt-prefix caching rather than of that destination, so it is now universal +and the model/registry test is gone. + +Role is separate. `developer` is part of the Chat Completions role set and is now forwarded as +sent. A destination that genuinely rejects it sets `foldDeveloperRoleToSystem`, which converts +the role in place and never moves the message, so the placement contract holds on both paths. + +### #5212 — inline document bytes + +Both inbound parsers reduced an attachment to its name before any adapter ran. +`OcxContentPart` gains a document member carrying the media type and the base64 payload; +Anthropic emits the document block, OpenAI Chat the file part, Gemini `inline_data`. + +Widening that union is the hazard, so the part also carries the marker every text-only consumer +already falls back to, which keeps a wire with no document representation byte-identical to +before. Six consumers needed more than the fallback: `ollama-native` and the Cursor tool-result +decoder would have read a nonexistent `imageUrl`, and the Kiro, Devin, Cursor and coding-agent +text serializers would have produced an empty turn. All were found by adversarial review. + +The untranslated-media refusal is narrowed only where a converter actually builds the part: +user content on the Chat projection, user and developer messages on the Responses one. A file in +a tool output, a system message or an assistant message is still refused. The scanner and the +decoder share one predicate, so a request cannot be exempted in one and reduced to a marker in +the other. + +## Known remaining gap + +Tool-result documents keep the #939 marker. The Responses tool-output vocabulary has no file +block and every adapter's tool-result path flattens to text, so carrying bytes there is a +separate change rather than a half-done one. + +## Union-defect check before push + +- File-size ratchet: `src/adapters/openai-chat.ts` was the only capped file in the touch set + (cap 822). The `parallel_tool_calls` decision moved to a sibling module and the file is 811 + lines. No cap was raised. +- `PROVIDER_CONFIG_FIELD_POLICY` in `src/server/auth-cors.ts` is + `satisfies Record`, so `foldDeveloperRoleToSystem` is classified + there and in `providerConfigSchema`. +- Every new test file is registered in both `scripts/test-layout/layout.json` and + `tests/fixtures/test-layout-expected.json`, which the layout guard asserts are equal. +- No count is restated: the provider reference tables gained a row rather than a number. + +## Verification + +Static source review plus exact-head hosted CI. Local suites, individual tests, typecheck, +build, install and live `ocx` execution were NOT RUN, per the lane constraints. Adversarial +source review ran on every commit and produced the passthrough, Kiro/Devin/Cursor/coding-agent, +role-aware-refusal and base64-predicate findings listed above. diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index d207a3bc003..3e8decdd978 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -628,4 +628,4 @@ Utilisez `"haiku"` comme valeur de remplacement pour le modèle. Dans `config.json`, `claudeCode.stabilizePromptCache: true` déplace les notices Claude reconnues en fin des instructions système vers un dernier message utilisateur sur les routes traduites. La valeur par défaut est `false`. Activez cette option seulement si ce changement de rôle convient à vos clients. Les exemples dans des blocs de code et le texte non reconnu sont conservés ; le transfert Anthropic natif reste inchangé. Sans métadonnées, la clé de cache suit les instructions stabilisées. Cette option ne crée pas une identité de conversation et ne garantit aucun succès du cache amont. -Sur la route Chat d’OpenCode Go pour `deepseek-v4.1-flash`, les rappels système traduits dans l’historique conservent automatiquement leur position et leur rôle system, après les résultats d’outils encore attendus. Ainsi, l’ajout de rappels ne réécrit pas le prompt système initial. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; la conversion des autres modèles et destinations, ainsi que le transfert Anthropic natif, restent inchangés. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. +Sur toutes les routes Chat traduites, les rappels de l’historique conservent leur position dans la conversation, après les résultats d’outils encore attendus, et sont transmis avec le rôle `developer`. L’ajout d’un rappel ne réécrit donc pas le prompt système initial, et une instruction placée au milieu de la conversation n’arrive plus avant les tours qu’elle était censée suivre. Si le service en amont refuse le rôle `developer`, activez `foldDeveloperRoleToSystem` sur ce fournisseur : le rappel est alors envoyé en `system`, à la même position. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; le transfert Anthropic natif reste inchangé. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index a96d502b6c4..be5c9dcad1f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -132,6 +132,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | | `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | +| `foldDeveloperRoleToSystem?` | `boolean` | Envoyer un message `developer` en `system` pour un fournisseur `openai-chat` dont le service en amont refuse le rôle `developer`. Dans les deux cas le message conserve sa position dans la conversation ; seul le rôle change. La valeur par défaut est `false`, donc le rôle standard de Chat Completions est transmis tel quel. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 89078ca1f6d..5f04d0f41b9 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -708,12 +708,14 @@ route. Pass `"haiku"` as the model placeholder. Set `claudeCode.stabilizePromptCache` to `true` in `config.json` to relocate supported trailing Claude harness notices from system instructions to a trailing user message on translated routes. The default is `false`. Enable it only when this role change is appropriate for your clients. It preserves fenced examples and unmatched text; native Anthropic passthrough is unchanged. The metadata-less prompt-cache key then follows stabilized instructions. This does not create conversation identity or guarantee upstream cache hits. -On OpenCode Go's `deepseek-v4.1-flash` Chat route, translated timeline system -reminders automatically retain their position and system role, after any pending -tool results. This prevents newly appended reminders from rewriting the leading -system prompt. It applies with or without `stabilizePromptCache`; other models -and destinations keep their existing conversion; native Anthropic passthrough -is unchanged. Cache reuse still requires stable session identity and upstream -cache availability. Changes to earlier instructions or tools, and conversation -compaction, can still affect cache hits; preserving reminder order alone does -not guarantee reuse. +On every translated Chat route, timeline reminders keep their position in the +conversation, after any pending tool results, and are forwarded with the +`developer` role. This prevents a newly appended reminder from rewriting the +leading system prompt, and stops a mid-conversation instruction from arriving +ahead of the turns it was written to follow. Set `foldDeveloperRoleToSystem` on +a provider whose upstream rejects the `developer` role; the reminder is then +sent as `system` in the same position. This applies with or without +`stabilizePromptCache`, and native Anthropic passthrough is unchanged. Cache +reuse still requires stable session identity and upstream cache availability. +Changes to earlier instructions or tools, and conversation compaction, can still +affect cache hits; preserving reminder order alone does not guarantee reuse. diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index d7b9e3c3edf..99f44144f9b 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -146,14 +146,17 @@ An explicit reasoning effort of `none` survives Chat conversion. Output limits a controls are preserved for generic API-key Responses targets; the canonical ChatGPT target still applies its own restrictions. This does not make all providers' controls equivalent. -**Audio and files need a native input wire that supports them.** OpenCodex does not yet have -a lossless audio/file carrier for translated requests. When Chat requires projection, or a -Responses request targets a translated adapter, recognized audio/file attachments return an -explicit error rather than succeeding without the attachment. File-ID-only images have the -same restriction because translated adapters cannot resolve those IDs. Convert the attachment -to text first, or use a native wire and model that support it. Native Chat and raw Responses -(including Azure) retain their existing behavior; this is not a promise of every model's -upstream media support. Video conversion limits remain adapter-specific. +**Audio and most file attachments need a native input wire that supports them.** A document +that carries its own base64 bytes in a user message is the exception: it survives translation +and reaches the Anthropic, OpenAI Chat and Google wires as a native document, file part and +inline data part. Everything else still returns an explicit error rather than succeeding +without the attachment — audio, a file-ID or remote reference the proxy cannot dereference, an +attachment in a tool output or a system message, and a document routed to a wire with no byte +carrier. File-ID-only images have the same restriction because translated adapters cannot +resolve those IDs. Convert the attachment to text first, or use a native wire and model that +support it. Native Chat and raw Responses (including Azure) retain their existing behavior; +this is not a promise of every model's upstream media support. Video conversion limits remain +adapter-specific. ## Schema status diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 4dfa380673f..b797f5a69e0 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -498,4 +498,4 @@ Anthropic バックエンドを明示すると意図的に失敗後停止しま `config.json` の `claudeCode.stabilizePromptCache` を `true` にすると、変換ルートのシステム指示末尾にある対応済み Claude 通知を最後のユーザーメッセージへ移します。既定値は `false` です。このロール変更が適切なクライアントでのみ有効にしてください。コードフェンス内の例と一致しない本文は保持され、Anthropic のネイティブ転送は変わりません。メタデータがない場合のキャッシュキーは安定化した指示から計算されます。会話 ID の生成やキャッシュヒットの保証は行いません。 -OpenCode Go の `deepseek-v4.1-flash` Chat ルートでは、変換されたタイムライン上のシステムリマインダーは、保留中のツール結果の後で位置と system ロールを自動的に維持します。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わりません。`stabilizePromptCache` の設定にかかわらず適用され、他のモデルや接続先の変換、および Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 +変換されたすべての Chat ルートで、タイムライン上のリマインダーは保留中のツール結果の後、会話内の元の位置を保ったまま `developer` ロールで転送されます。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わらず、会話の途中に置かれた指示がそれより前のターンの前に移動することもありません。上流が `developer` ロールを受け付けない場合は、そのプロバイダーに `foldDeveloperRoleToSystem` を設定してください。同じ位置のまま `system` として送信されます。`stabilizePromptCache` の設定にかかわらず適用され、Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a9fdaf8c813..20633fc2da4 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -125,6 +125,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 上流が `developer` ロールを受け付けない `openai-chat` プロバイダーで、`developer` メッセージを `system` として送ります。どちらの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。既定は `false` で、標準の Chat Completions ロールをそのまま転送します。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 97ccbf35666..9dbce2e526d 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -563,4 +563,4 @@ Anthropic 백엔드를 명시하면 의도적으로 실패 후 중단해요. `config.json`에서 `claudeCode.stabilizePromptCache`를 `true`로 설정하면 번역 경로의 시스템 지시 끝에 붙은 지원 대상 Claude 알림을 마지막 사용자 메시지로 옮깁니다. 기본값은 `false`입니다. 사용하는 클라이언트에서 이 역할 변경을 허용할 때만 켜세요. 코드 펜스 안의 예제와 일치하지 않는 원문은 보존하며, Anthropic 원본 전달 경로는 바꾸지 않습니다. 메타데이터가 없는 요청의 캐시 키는 정리된 지시문을 기준으로 계산합니다. 대화 식별자를 만들거나 상위 서비스의 캐시 적중을 보장하는 기능은 아닙니다. -OpenCode Go의 `deepseek-v4.1-flash` Chat 경로에서는 변환된 타임라인 시스템 알림이 대기 중인 도구 결과 뒤에서 원래 위치와 system 역할을 자동으로 유지합니다. 따라서 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않습니다. `stabilizePromptCache` 설정과 관계없이 적용되며, 다른 모델과 대상의 변환 및 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. +변환된 모든 Chat 경로에서 타임라인 알림은 대기 중인 도구 결과 뒤, 대화 안의 원래 위치를 그대로 유지하며 `developer` 역할로 전달됩니다. 덕분에 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않고, 대화 중간의 지시가 그 지시보다 앞선 턴으로 끌려가지도 않습니다. 상위 서비스가 `developer` 역할을 거부한다면 해당 공급자에 `foldDeveloperRoleToSystem`을 설정하세요. 그러면 같은 위치에서 `system`으로 보냅니다. `stabilizePromptCache` 설정과 관계없이 적용되며 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 55e1f4457d4..3731b1b3eb4 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -125,6 +125,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | +| `foldDeveloperRoleToSystem?` | `boolean` | 상위 서비스가 `developer` 역할을 거부하는 `openai-chat` 공급자에서 `developer` 메시지를 `system`으로 보냅니다. 어느 쪽이든 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 기본값은 `false`이며, 표준 Chat Completions 역할을 받은 그대로 전달합니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 29f66c70ffa..445e99df483 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -201,6 +201,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | +| `foldDeveloperRoleToSystem?` | `boolean` | Send a `developer` message as `system` on an `openai-chat` provider whose upstream rejects the `developer` role. The message keeps its position in the conversation either way; only the role changes. Defaults to `false`, so the standard Chat Completions role is forwarded as sent. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 1ceea7da0e8..795a1a80d89 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -529,4 +529,4 @@ Responses `web_search_call` в парные блоки Anthropic `server_tool_us Параметр `claudeCode.stabilizePromptCache: true` в `config.json` переносит поддерживаемые уведомления Claude в конце системных инструкций в последнее пользовательское сообщение на маршрутах с преобразованием. По умолчанию он выключен (`false`). Включайте его только когда такое изменение роли допустимо для ваших клиентов. Примеры в блоках кода и нераспознанный текст сохраняются; нативная передача Anthropic не меняется. Без метаданных ключ кэша рассчитывается по стабилизированным инструкциям. Идентификатор разговора не создаётся, попадания в кэш не гарантируются. -На Chat-маршруте OpenCode Go для `deepseek-v4.1-flash` преобразованные системные напоминания в истории автоматически сохраняют свою позицию и роль system после ожидаемых результатов инструментов. Поэтому добавление новых напоминаний не переписывает начальный системный промпт. Это работает независимо от `stabilizePromptCache`; преобразование для других моделей и адресатов, а также нативная передача Anthropic остаются прежними. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. +На всех преобразованных Chat-маршрутах напоминания в истории сохраняют свою позицию в разговоре — после ожидаемых результатов инструментов — и передаются с ролью `developer`. Поэтому добавление нового напоминания не переписывает начальный системный промпт, а инструкция из середины разговора не оказывается раньше тех ходов, после которых она была написана. Если вышестоящий сервис не принимает роль `developer`, задайте у этого провайдера `foldDeveloperRoleToSystem`: напоминание будет отправлено как `system` в той же позиции. Это работает независимо от `stabilizePromptCache`; нативная передача Anthropic остаётся прежней. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 21227ef1abd..1c73ea4571e 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -138,6 +138,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | +| `foldDeveloperRoleToSystem?` | `boolean` | Отправлять сообщение `developer` как `system` для провайдера `openai-chat`, чей вышестоящий сервис не принимает роль `developer`. В обоих случаях сообщение сохраняет свою позицию в разговоре; меняется только роль. По умолчанию `false`, то есть стандартная роль Chat Completions передаётся как есть. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 4920210639a..24efef89db3 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -738,4 +738,4 @@ tutucusu olarak `"haiku"` iletin. `config.json` içindeki `claudeCode.stabilizePromptCache: true`, dönüştürülen rotalarda sistem talimatlarının sonundaki desteklenen Claude bildirimlerini son kullanıcı mesajına taşır. Varsayılan değer `false` olur. Yalnızca bu rol değişikliği istemcileriniz için uygunsa etkinleştirin. Kod bloklarındaki örnekler ve eşleşmeyen metin korunur; yerel Anthropic aktarımı değişmez. Meta veri yoksa önbellek anahtarı kararlı talimatlardan hesaplanır. Bu seçenek konuşma kimliği oluşturmaz veya üst hizmette önbellek isabeti garanti etmez. -OpenCode Go’nun `deepseek-v4.1-flash` Chat rotasında, dönüştürülen zaman çizelgesi sistem hatırlatmaları bekleyen araç sonuçlarından sonra konumlarını ve system rolünü otomatik olarak korur. Böylece yeni hatırlatmalar eklenmesi, baştaki sistem istemini yeniden yazmaz. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; diğer modellerin ve hedeflerin dönüşümü ile yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. +Dönüştürülen tüm Chat rotalarında zaman çizelgesi hatırlatmaları, bekleyen araç sonuçlarından sonra konuşmadaki konumlarını korur ve `developer` rolüyle iletilir. Böylece yeni bir hatırlatma eklenmesi baştaki sistem istemini yeniden yazmaz ve konuşmanın ortasındaki bir yönerge, izlemesi gereken turların önüne geçmez. Üst hizmet `developer` rolünü kabul etmiyorsa ilgili sağlayıcıda `foldDeveloperRoleToSystem` ayarını açın; hatırlatma aynı konumda `system` olarak gönderilir. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 57f5d867189..447f02b49d5 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -139,6 +139,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | +| `foldDeveloperRoleToSystem?` | `boolean` | Üst hizmeti `developer` rolünü kabul etmeyen bir `openai-chat` sağlayıcısında `developer` mesajını `system` olarak gönderir. Her iki durumda da mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Varsayılan `false` olduğundan standart Chat Completions rolü geldiği gibi iletilir. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index db814d3aef7..6c65903fa46 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -467,4 +467,4 @@ Claude 模型时自动加载。对于原生透传,这是正常现象;对于 在 `config.json` 中设置 `claudeCode.stabilizePromptCache: true`,可在转换路由上将系统指令末尾受支持的 Claude 提示移到最后一条用户消息。默认值为 `false`。仅在客户端允许这种角色变化时启用。代码围栏内的示例和不匹配的文本会保留,Anthropic 原生透传不变。没有元数据时,缓存键按稳定后的指令计算。该选项不会生成会话标识,也不保证上游缓存命中。 -在 OpenCode Go 的 `deepseek-v4.1-flash` Chat 路由上,转换后的时间线系统提醒会自动保留原有位置和 system 角色,并排在尚待返回的工具结果之后。因此,追加提醒不会重写开头的系统提示。无论 `stabilizePromptCache` 是否启用,该行为都会生效;其他模型、目标地址的转换方式以及 Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 +在所有转换后的 Chat 路由上,时间线提醒都会保留在对话中的原有位置(排在尚待返回的工具结果之后),并以 `developer` 角色转发。因此,追加提醒不会重写开头的系统提示,对话中途的指令也不会被挪到它本应跟随的轮次之前。如果上游拒绝 `developer` 角色,请在该提供方上设置 `foldDeveloperRoleToSystem`,提醒会在同一位置以 `system` 发送。无论 `stabilizePromptCache` 是否启用,该行为都会生效;Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index e0e61490a1d..8fcfa10e6e8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -125,6 +125,7 @@ selector,而不是分配一个新名称。 | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 对上游拒绝 `developer` 角色的 `openai-chat` 提供方,将 `developer` 消息作为 `system` 发送。无论哪种方式,消息都会保留在对话中的原有位置,只有角色改变。默认为 `false`,即按原样转发标准的 Chat Completions 角色。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 493aeea9a5b..51231f1a38d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -538,4 +538,4 @@ Claude 模型時自動載入。對於原生透傳,這是正常現象;對於 在 `config.json` 中設定 `claudeCode.stabilizePromptCache: true`,可在轉換路由上將系統指令末尾支援的 Claude 提示移到最後一則使用者訊息。預設值為 `false`。僅在用戶端允許這種角色變更時啟用。程式碼圍欄中的範例和不符合的文字會保留,Anthropic 原生轉送不變。沒有中繼資料時,快取鍵依穩定後的指令計算。此選項不會產生對話識別碼,也不保證上游快取命中。 -在 OpenCode Go 的 `deepseek-v4.1-flash` Chat 路由上,轉換後的時間線系統提醒會自動保留原有位置和 system 角色,並排在尚待傳回的工具結果之後。因此,新增提醒不會重寫開頭的系統提示。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;其他模型、目標位址的轉換方式以及 Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 +在所有轉換後的 Chat 路由上,時間線提醒都會保留在對話中的原有位置(排在尚待傳回的工具結果之後),並以 `developer` 角色轉送。因此,新增提醒不會重寫開頭的系統提示,對話中途的指令也不會被移到它原本應跟隨的輪次之前。若上游拒絕 `developer` 角色,請在該提供者上設定 `foldDeveloperRoleToSystem`,提醒會在相同位置以 `system` 傳送。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 395ec06ec77..15e4ce53a37 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -99,6 +99,7 @@ ocx models provider openrouter on | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 對上游拒絕 `developer` 角色的 `openai-chat` 提供者,將 `developer` 訊息以 `system` 傳送。無論何者,訊息都會保留在對話中的原有位置,只有角色改變。預設為 `false`,亦即照原樣轉送標準的 Chat Completions 角色。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1a31307d1e6..5c26e697f18 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1568,6 +1568,11 @@ "management-google-tool-schema-policy.test.ts": "server", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "chat-tool-choice-allowed-tools.test.ts": "responses", + "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", + "google-strict-tool-validated-mode.test.ts": "adapters/google", + "openai-chat-developer-position.test.ts": "adapters/openai", + "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" }, diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index c90f8009a66..9e2e1ffb425 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -39,6 +39,16 @@ function toAnthropicContentPart(p: OcxContentPart): unknown { : { type: "image", source: { type: "url", url: p.imageUrl } }; } if (p.type === "video") return { type: "text", text: "[video]" }; + // The block the caller sent, rebuilt. A Messages-to-Messages route used to reduce it to its + // title before any adapter ran, so the model was told a document existed rather than given + // one, and the answer came back looking the same (#5212). + if (p.type === "document") { + return { + type: "document", + source: { type: "base64", media_type: p.mediaType, data: p.data }, + ...(p.filename !== undefined ? { title: p.filename } : {}), + }; + } return { type: "text", text: p.text }; } @@ -857,6 +867,12 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: ( name: toolNames.toWire(namespacedToolName(t.namespace, t.name)), description: t.description, input_schema: normalizeAnthropicInputSchema(t.parameters), + // Anthropic is the target that DEFINES both of these, and both were dropped while the + // OpenAI Chat adapter already forwarded strict (#5210). Only an explicit `true` is + // emitted: the Messages inbound records an absent strict as `false`, so a false here + // cannot be distinguished from silence and must not become an opt-out on the wire. + ...(t.strict === true ? { strict: true } : {}), + ...(t.allowedCallers !== undefined ? { allowed_callers: [...t.allowedCallers] } : {}), })); return converted; } diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 628341bb8d1..4d38ac071e1 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -332,7 +332,7 @@ function formatMessageForHistory(message: OcxMessage): string { if (message.role === "user") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" ? p.text : `[${p.type}]`)).join("\n"); + : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : `[${p.type}]`)).join("\n"); return `USER:\n${text}`; } if (message.role === "assistant") { @@ -352,7 +352,7 @@ function formatMessageForHistory(message: OcxMessage): string { if (message.role === "toolResult") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" ? p.text : "[image]")).join(""); + : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : "[image]")).join(""); const status = message.isError ? " (error)" : ""; return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`; } @@ -379,6 +379,8 @@ export function buildInputLines(message: OcxMessage): string[] { else if (part.type === "image") { const image = imagePart(part.imageUrl); if (image) content.push(image); + } else if (part.type === "document") { + content.push(textPart(part.text)); } else { content.push(textPart("[video]")); } @@ -402,7 +404,7 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined if (message.role !== "developer") continue; const text = typeof message.content === "string" ? message.content - : message.content.map(part => (part.type === "text" ? part.text : "")).join(""); + : message.content.map(part => (part.type === "text" || part.type === "document" ? part.text : "")).join(""); if (text.trim()) parts.push(text); } return parts.length > 0 ? parts.join("\n\n") : undefined; @@ -464,6 +466,8 @@ export function buildConversationInput(parsed: OcxParsedRequest, options: { maxH const image = imagePart(part.imageUrl); if (image) currentImageBlocks.push(image); else textParts.push("[image omitted: unsupported reference]"); + } else if (part.type === "document") { + textParts.push(part.text); } else { textParts.push("[video]"); } @@ -487,6 +491,7 @@ export function buildConversationInput(parsed: OcxParsedRequest, options: { maxH else segments.push("[image omitted: unsupported reference]"); continue; } + if (part.type === "document") { segments.push(part.text); continue; } segments.push("[video]"); } text = segments.join(""); diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 35d54ebd463..c05263110f9 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -32,7 +32,7 @@ function canonicalCommandCodeModelId(modelId: string): string { /** Flatten tool-result content for the text-only wire output, keeping an `[image]` marker per image part in content order. */ function toolResultText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - return content.map(part => (part.type === "text" ? part.text : "[image]")).join(""); + return content.map(part => (part.type === "text" || part.type === "document" ? part.text : "[image]")).join(""); } /** Best-effort media type from a remote https URL extension, e.g. image/png. */ @@ -149,6 +149,7 @@ function wireMessages(messages: OcxMessage[]): Array> { else for (const part of message.content) { if (part.type === "text") content.push({ type: "text", text: part.text }); else if (part.type === "image") content.push(wireImagePart(part.imageUrl)); + else if (part.type === "document") content.push({ type: "text", text: part.text }); else content.push({ type: "text", text: "[video]" }); } out.push({ role: "user", content }); diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 894cf3eae45..83e076dcced 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -741,7 +741,7 @@ function contentText(message: OcxMessage): string { if (typeof message.content === "string") return message.content; return message.content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "thinking") return part.thinking; if (part.type === "image") return undefined; return undefined; @@ -754,7 +754,7 @@ function contentToText(content: OcxToolResultMessage["content"]): string { if (typeof content === "string") return content; return content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER; return undefined; }) @@ -767,7 +767,7 @@ function historyContentText(message: OcxMessage): string { if (message.role === "toolResult" || typeof message.content === "string") return contentText(message); return message.content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "thinking") return part.thinking; if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER; return undefined; @@ -836,6 +836,9 @@ function decodeResultParts(message: OcxToolResultMessage): DecodedResultPart[] | return content.map((part): DecodedResultPart => { if (part.type === "text") return { kind: "text", text: part.text }; if (part.type === "video") return { kind: "text", text: "[video]" }; + // Without this the document falls through to decodeInlineImage(part.imageUrl) below and is + // treated as an image it is not. + if (part.type === "document") return { kind: "text", text: part.text }; const decoded = decodeInlineImage(part.imageUrl); return decoded ? { kind: "image", ...decoded } : { kind: "undecodable" }; }); diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 065f971afa3..3c0e5faa802 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -251,6 +251,9 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri switch (part.type) { case "text": return part.text; + case "document": + // Cursor has no document carrier; the marker keeps the turn from serializing to nothing. + return part.text; case "thinking": return part.thinking; case "image": diff --git a/src/adapters/declaration-carrier.ts b/src/adapters/declaration-carrier.ts new file mode 100644 index 00000000000..a1f99739c97 --- /dev/null +++ b/src/adapters/declaration-carrier.ts @@ -0,0 +1,45 @@ +// Type-only: erased at compile time, so this does not create an import cycle with the registry. +import type { AdapterWire } from "./registry"; +import type { OcxMessage, OcxParsedRequest } from "../types"; +import { toolRestrictsCallers } from "../types"; + +/** + * Wires that can actually carry a constraint, as a default-deny allowlist. + * + * A per-adapter opt-in is the wrong shape for this: an adapter that never learned about a + * carrier rebuilds the declaration or the message without it and returns a normal completion, + * which is exactly the silent widening this batch exists to remove. Listing the wires that CAN + * carry it means a new wire refuses until someone teaches it, and adding a member to + * `AdapterWire` makes the omission visible here rather than at a customer's upstream. + */ +const CALLER_RESTRICTION_WIRES: ReadonlySet = new Set(["anthropic"]); +const INLINE_DOCUMENT_WIRES: ReadonlySet = new Set([ + "anthropic", + "openai-chat", + "google", +]); + +/** A fixed-vocabulary refusal, or `undefined` when this wire can hold everything the request carries. */ +export function unrepresentableDeclaration(parsed: OcxParsedRequest, wire: AdapterWire): string | undefined { + if (!CALLER_RESTRICTION_WIRES.has(wire) && parsed.context.tools?.some(toolRestrictsCallers)) { + // The name is deliberately absent: it is caller-controlled and would put client metadata + // into an error body. + return "OpenCodex cannot express tools[].allowed_callers on this route. " + + "Route the request to an Anthropic-protocol provider, or remove the caller restriction."; + } + if (!INLINE_DOCUMENT_WIRES.has(wire) && parsed.context.messages.some(carriesDocument)) { + return "OpenCodex cannot translate document input on this route. " + + "Use a native input wire that supports the attachment, or convert it to text first."; + } + return undefined; +} + +/** + * Typed structurally rather than as `OcxContentPart[]`: an assistant turn's content is + * `OcxAssistantContentPart[]`, which carries thinking and tool-call members and is not + * assignable to the user-content union. Only the discriminant is read here. + */ +function carriesDocument(message: OcxMessage): boolean { + const content: unknown = message.content; + return Array.isArray(content) && (content as ReadonlyArray<{ type: string }>).some(part => part.type === "document"); +} diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index c1c67bdd409..fe81f356ba4 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -331,6 +331,9 @@ function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): st for (const part of content) { if (part.type === "text" && part.text) { out.push({ type: "text", text: part.text }); + } else if (part.type === "document") { + // No Devin document field; the marker keeps the turn from disappearing entirely. + out.push({ type: "text", text: part.text }); } else if (part.type === "image") { const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/); if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! }); diff --git a/src/adapters/google-antigravity-wire.ts b/src/adapters/google-antigravity-wire.ts index a40703f8377..223d95293e4 100644 --- a/src/adapters/google-antigravity-wire.ts +++ b/src/adapters/google-antigravity-wire.ts @@ -45,8 +45,11 @@ function firstUserText(parsed: OcxParsedRequest): string | undefined { for (const msg of parsed.context.messages) { if (msg.role !== "user") continue; if (typeof msg.content === "string") return msg.content; - const first = (msg.content as OcxContentPart[]).find(p => p.type === "text" && typeof p.text === "string"); - if (first && first.type === "text") return first.text; + // A document part carries text too: ignoring it left a document-only opening turn with no + // anchor, which silently downgrades the deterministic session id to a random one. + const first = (msg.content as OcxContentPart[]) + .find(p => (p.type === "text" || p.type === "document") && typeof p.text === "string"); + if (first && (first.type === "text" || first.type === "document")) return first.text; } return undefined; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 1e8631ae645..a82c240a1d9 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -15,6 +15,7 @@ import type { OcxUsage, } from "../types"; import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; +import type { OcxTool } from "../types"; import { contentPartsToText, parseDataUrl } from "./image"; import { getVertexAccessToken } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; @@ -346,6 +347,13 @@ function messagesToGeminiFormat( parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[video: ${p.videoUrl}]` }); continue; } + if (p.type === "document") { + // Gemini takes document bytes through the same inline_data part as images and + // video. The marker on the part is the fallback for wires without one, not this + // wire's best effort (#5212). + parts.push({ inline_data: { mime_type: p.mediaType, data: p.data } }); + continue; + } // Drop empty/malformed text instead of emitting `{ text: "" }` or a bare `{}` part. const textPart = geminiTextPart(p.text); if (textPart) parts.push(textPart); @@ -465,9 +473,7 @@ function messagesToGeminiFormat( function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { if (!parsed.context.tools?.length) return undefined; - const tools = isAllowedToolChoice(parsed.options.toolChoice) - ? parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)) - : parsed.context.tools; + const tools = advertisedGeminiTools(parsed); if (tools.length === 0) return undefined; return [{ functionDeclarations: tools.map(t => ({ @@ -478,19 +484,37 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { }]; } +/** The declarations this request actually advertises, after any allowed-tools filter. */ +function advertisedGeminiTools(parsed: OcxParsedRequest): readonly OcxTool[] { + const declared = parsed.context.tools ?? []; + return isAllowedToolChoice(parsed.options.toolChoice) + ? declared.filter(toolChoiceToolPredicate(parsed.options.toolChoice, declared)) + : declared; +} + /** * Client tool_choice enforcement on the wire. The catalog nudge states the same contract in * prose, but without functionCallingConfig the model is free to ignore it. "auto" stays absent * so the common case is byte-identical. The allowedTools variant already filters the * declarations in toolsToGeminiFormat; only its "required" half needs a wire mode. + * + * A caller that declares strict tools is asking for its argument schemas to be enforced, and + * Gemini expresses that as VALIDATED. The mode existed and was plumbed end to end, but was only + * ever reachable by matching a model name, so a strict declaration arrived as an ordinary + * unvalidated AUTO turn and the response looked the same either way (#5210). VALIDATED replaces + * AUTO only: ANY and NONE are stronger constraints the caller asked for explicitly, and + * overwriting either of them would lose the choice this function exists to enforce. */ function toolChoiceToGeminiToolConfig(parsed: OcxParsedRequest): Record | undefined { const choice = parsed.options.toolChoice; - if (!choice || choice === "auto") return undefined; + const validated = advertisedGeminiTools(parsed).some(t => t.strict === true) + ? { functionCallingConfig: { mode: "VALIDATED" } } + : undefined; + if (!choice || choice === "auto") return validated; if (choice === "none") return { functionCallingConfig: { mode: "NONE" } }; if (choice === "required") return { functionCallingConfig: { mode: "ANY" } }; if (isAllowedToolChoice(choice)) { - return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : undefined; + return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : validated; } return { functionCallingConfig: { diff --git a/src/adapters/image.ts b/src/adapters/image.ts index 39c5d683e50..1a6569fb0f4 100644 --- a/src/adapters/image.ts +++ b/src/adapters/image.ts @@ -18,6 +18,9 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string } */ export function contentPartsToText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - const text = content.map(p => p.type === "text" ? p.text : p.type === "image" ? "[image]" : "[video]").join(""); + // A document carries its own marker, so this wire states the attachment instead of + // mislabelling it as a video. + const text = content.map(p => + p.type === "text" || p.type === "document" ? p.text : p.type === "image" ? "[image]" : "[video]").join(""); return text || "[image]"; } diff --git a/src/adapters/input-media-guard.ts b/src/adapters/input-media-guard.ts index ad8f468a95b..96c554782d1 100644 --- a/src/adapters/input-media-guard.ts +++ b/src/adapters/input-media-guard.ts @@ -1,31 +1,45 @@ import type { ProviderAdapter } from "./base"; import { untranslatedInputMediaMessage, untranslatedResponsesInputMedia } from "../responses/input-media"; +import { unrepresentableDeclaration } from "./declaration-carrier"; +import type { AdapterWire } from "./registry"; +import type { OcxParsedRequest } from "../types"; /** * Refuse unrepresentable input at the final translated-adapter boundary. The registry * applies this after wire resolution; Responses passthrough (including Azure) opts * out because it uses the original body rather than the lossy normalized content. + * + * Two of the three checks are wire-scoped, and that is the point. A constraint the normalized + * request CAN carry — a caller restriction on a tool, an attached document's bytes — still has + * to reach a wire that can express it. Leaving that to each adapter means an adapter that never + * learned about the carrier rebuilds without it and answers normally, so the allowlist in + * `declaration-carrier.ts` is default-deny and this is the one place every registered adapter + * passes through. */ -export function withInputMediaGuard(adapter: T): T { +export function withInputMediaGuard(adapter: T, wire: AdapterWire): T { + const refusal = (parsed: OcxParsedRequest): string | undefined => { + const kind = untranslatedResponsesInputMedia(parsed._rawBody); + return kind ? untranslatedInputMediaMessage(kind) : unrepresentableDeclaration(parsed, wire); + }; const build = adapter.buildRequest.bind(adapter); adapter.buildRequest = (parsed, incoming) => { - const kind = untranslatedResponsesInputMedia(parsed._rawBody); - if (kind) throw new Error(untranslatedInputMediaMessage(kind)); + const message = refusal(parsed); + if (message) throw new Error(message); return build(parsed, incoming); }; const runTurn = adapter.runTurn?.bind(adapter); if (runTurn) { adapter.runTurn = async (parsed, incoming, emit) => { - const kind = untranslatedResponsesInputMedia(parsed._rawBody); - if (kind) { + const message = refusal(parsed); + if (message) { emit({ type: "error", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false, - message: untranslatedInputMediaMessage(kind), + message, }); return; } @@ -37,9 +51,7 @@ export function withInputMediaGuard(adapter: T): T { if (localTerminal) { // This hook is outside the builder's error catch. Decline its success shortcut; // the ordinary buildRequest path then returns the established client-safe 400. - adapter.localTerminal = parsed => untranslatedResponsesInputMedia(parsed._rawBody) - ? undefined - : localTerminal(parsed); + adapter.localTerminal = parsed => refusal(parsed) ? undefined : localTerminal(parsed); } return adapter; } diff --git a/src/adapters/kiro-tool-fallback.ts b/src/adapters/kiro-tool-fallback.ts index 1164277368a..24c917f2853 100644 --- a/src/adapters/kiro-tool-fallback.ts +++ b/src/adapters/kiro-tool-fallback.ts @@ -9,7 +9,7 @@ function contentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; return content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "image") return `[image:${part.detail ?? "auto"}]`; return ""; }) diff --git a/src/adapters/kiro/usage.ts b/src/adapters/kiro/usage.ts index 5f417b57295..3bb8f2b0ffe 100644 --- a/src/adapters/kiro/usage.ts +++ b/src/adapters/kiro/usage.ts @@ -12,14 +12,15 @@ import type { KiroHistoryEntry } from "./wire"; export function userContentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - return content.map(p => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n"); + // A document carries its own marker: dropping it built an empty user turn that Kiro rejects. + return content.map(p => (p.type === "text" || p.type === "document" ? p.text : "")).filter(Boolean).join("\n"); } export function usageContentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; return content .map(p => { - if (p.type === "text") return p.text; + if (p.type === "text" || p.type === "document") return p.text; if (p.type === "image") return `[image:${p.detail ?? "auto"}]`; return ""; }) diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index c78f154d49d..726fe55ad49 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -262,6 +262,12 @@ function contentToNative( text += part.text; continue; } + // No Ollama document carrier: keep the marker rather than falling through to the image + // branch below, which would read a nonexistent imageUrl. + if (part.type === "document") { + text += part.text; + continue; + } // Ollama's native /api/chat message shape carries `images: string[]` and has no video // counterpart, so a video part is refused rather than silently dropped or mis-sent as an image. if (part.type === "video") throw new Error(`ollama-native cannot send video content in ${label}`); diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 633c693c7ff..fa024e0cccb 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,4 +1,5 @@ import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; +import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; import { modelInList } from "../types"; @@ -249,19 +250,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (tools) { - if (provider.parallelToolCalls === false) { - // NIM documents the Boolean defaulting to false and kimi rejects true; pin the - // wire bit so Codex cannot opt in via request.options. Other opted-out providers - // omit the field by default so strict OpenAI-compatible hosts never see an - // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps - // emitting parallel calls without it can opt in via pinParallelToolCallsFalse. - if (provider.baseUrl === "https://integrate.api.nvidia.com/v1" - || provider.pinParallelToolCallsFalse === true) { - body.parallel_tool_calls = false; - } - } else if (provider.parallelToolCalls === true) { - body.parallel_tool_calls = parsed.options.parallelToolCalls !== false; - } + const parallelToolCalls = chatParallelToolCallsWireValue(provider, parsed.options.parallelToolCalls); + if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls; } if (parsed.stream) body.stream_options = { include_usage: true }; diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 8e88dabc6b0..c30072e1ee6 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -5,9 +5,9 @@ import { contentPartsToText } from "../image"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation"; import { identifyRoutedModel } from "../identity"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge"; -import { registryEntryForProviderDestination } from "../../providers/registry"; import { peekReasoningForCall } from "../../responses/reasoning-replay-cache"; -import type { OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; +import { inlineDocumentDataUrl } from "../../responses/inline-document"; +import type { OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; import { modelInList, namespacedToolName } from "../../types"; /** @@ -21,13 +21,6 @@ import { modelInList, namespacedToolName } from "../../types"; */ const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has no video mapping]"; -export function developerSystemText(message: OcxMessage): string | undefined { - if (message.role !== "developer") return undefined; - if (typeof message.content === "string") return message.content; - if (message.content.some(part => part.type === "image")) return undefined; - return message.content.map(part => (part as OcxTextContent).text).join(""); -} - /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" * content is text-only on every chat provider, so these ride in a follow-up user message instead of @@ -121,21 +114,29 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv }; const nativeOpenAI = isNativeOpenAIChatTarget(provider); - // Hoisting a newly appended reminder rewrites the reusable prompt prefix. - // Keep this compatibility exception on the destination/model tested with OCG. - const chronologicalSystem = parsed.modelId === "deepseek-v4.1-flash" - && registryEntryForProviderDestination(provider)?.id === "opencode-go"; + // `developer` is part of the Chat Completions role set, so it is forwarded as itself. The + // host test above used to decide the role too, which assumed every OpenAI-compatible gateway + // rejects a standard role until proven otherwise — including gateways that proxy OpenAI — + // and quietly gave the instruction `system` precedence instead (#5213). A destination that + // really does reject it records that with `foldDeveloperRoleToSystem`, which converts the + // role where the message already is and never moves it. + const developerWireRole = provider.foldDeveloperRoleToSystem === true ? "system" : "developer"; + // A developer message keeps the slot it arrived in. Hoisting its text into the leading + // system block moved a mid-conversation instruction ahead of every turn it was written to + // follow, and the caller saw an ordinary answer either way (#5213). The Claude inbound mints + // chronological developer items for exactly this reason (#4161), so the two halves of the + // route were working against each other on every host but api.openai.com. Placement is now + // uniform; which ROLE that slot carries is decided separately below. + // + // One destination already had the chronological behaviour, keyed to a model and a registry + // id, because hoisting a newly appended reminder rewrites the reusable prompt prefix. That is + // a property of prompt-prefix caching rather than of that destination, and it is now what + // every destination gets. const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider) ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice) : undefined; - const developerSystemParts = nativeOpenAI || chronologicalSystem - ? [] - : context.messages - .map(developerSystemText) - .filter((part): part is string => part !== undefined && part.length > 0); const systemParts = [ ...(context.systemPrompt ?? []), - ...developerSystemParts, ...(toolCatalogNudge ? [toolCatalogNudge] : []), ]; if (systemParts.length > 0) { @@ -152,20 +153,23 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv case "developer": { const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[]; const hasImages = parts?.some(p => p.type === "image") ?? false; + // A document has a structured counterpart on this wire, so it needs the parts array for + // the same reason an image does: flattening it to a string would drop the bytes. + const hasStructured = hasImages || (parts?.some(p => p.type === "document") ?? false); let chatMsg: Record; - if (msg.role === "developer" && !hasImages) { - if (!nativeOpenAI && !chronologicalSystem) break; + if (msg.role === "developer" && !hasStructured) { const text = typeof msg.content === "string" ? msg.content : parts!.map(p => (p as OcxTextContent).text).join(""); - // A non-text timeline part (video, for example) serializes to nothing here. - // The generic path drops such a message; the chronological exception must not - // turn it into an empty system message that some upstreams reject. + // A non-text timeline part (video, for example) serializes to nothing here. The + // generic user path drops such a message, and emitting a content-free system + // message instead is rejected by some upstreams. Native OpenAI keeps its existing + // empty-developer wire, which is a separate question from placement. if (!nativeOpenAI && text.length === 0) break; - chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text }; + chatMsg = { role: developerWireRole, content: text }; } else if (typeof msg.content === "string") { chatMsg = { role: "user", content: msg.content }; - } else if (!hasImages) { + } else if (!hasStructured) { // A video part has no `text`, so joining it produced "" and the whole message // was dropped: a video-only or text-plus-video turn vanished silently. OpenAI's // Chat Completions wire has no video content part, so state the omission @@ -183,13 +187,30 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv if (p.type === "image") { return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }; } + // Chat Completions carries an attached document as a file part with inline bytes, + // the direct counterpart of the Anthropic document block the caller sent. + if (p.type === "document") { + return { + type: "file", + file: { + file_data: inlineDocumentDataUrl(p), + ...(p.filename !== undefined ? { filename: p.filename } : {}), + }, + }; + } // Previously this produced { type: "text", text: undefined } for a video // part — a malformed part, worse than a drop because it can fail upstream // schema validation. if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER }; return { type: "text", text: (p as OcxTextContent).text }; }); - chatMsg = { role: "user", content: chatParts }; + // A developer message with images keeps the user-compatible shape it has always had on + // this wire. One carrying only a document has no such precedent, and demoting it would + // undo the role this adapter just finished preserving. + chatMsg = { + role: msg.role === "developer" && !hasImages ? developerWireRole : "user", + content: chatParts, + }; } if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg); else out.push(chatMsg); diff --git a/src/adapters/openai-chat/parallel-tool-calls.ts b/src/adapters/openai-chat/parallel-tool-calls.ts new file mode 100644 index 00000000000..89d6e4326f2 --- /dev/null +++ b/src/adapters/openai-chat/parallel-tool-calls.ts @@ -0,0 +1,32 @@ +import type { OcxProviderConfig } from "../../types"; + +/** + * The `parallel_tool_calls` value for a translated Chat request, or `undefined` to omit the key. + * + * A provider has three states here, not two, and the third is the default for every provider that + * never configured the knob. While the call site only branched on the two configured states, a + * caller's own explicit `parallel_tool_calls: false` reached the parser, was carried on + * `options.parallelToolCalls`, and was then dropped on the way to the wire — the upstream stayed + * free to emit concurrent calls and the response looked entirely normal (#5211). + * + * Only an explicit request-side `false` is forwarded in the unset state. `true` is already the + * upstream default, so emitting it would introduce the knob to strict OpenAI-compatible hosts + * that have never had to accept it, which is the reason the configured opt-out below omits the + * key rather than sending `false`. + */ +export function chatParallelToolCallsWireValue( + provider: OcxProviderConfig, + requested: boolean | undefined, +): boolean | undefined { + if (provider.parallelToolCalls === false) { + // NIM documents the Boolean defaulting to false and kimi rejects true; pin the wire bit so + // Codex cannot opt in via request.options. Other opted-out providers omit the field, but a + // self-hosted gateway that DOES honor it and keeps emitting parallel calls without it can opt + // in via pinParallelToolCallsFalse. + const pinned = provider.baseUrl === "https://integrate.api.nvidia.com/v1" + || provider.pinParallelToolCallsFalse === true; + return pinned ? false : undefined; + } + if (provider.parallelToolCalls === true) return requested !== false; + return requested === false ? false : undefined; +} diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index f7682b7a62d..e653017bbcc 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -9,6 +9,7 @@ import { debugProviderDiagnostic } from "../../lib/debug"; import { isDebugEnabled } from "../../lib/debug-settings"; import { modelRecordValue } from "../../reasoning-effort"; import { modelInList, type OcxProviderConfig } from "../../types"; +import { chatParallelToolCallsWireValue } from "./parallel-tool-calls"; const CHAT_PASSTHROUGH_FIELDS = [ "audio", @@ -108,12 +109,12 @@ export function buildOpenAIChatPassthroughRequest( body.prompt_cache_key = rawBody.prompt_cache_key; } if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) { - if (provider.parallelToolCalls === true) { - body.parallel_tool_calls = rawBody.parallel_tool_calls !== false; - } else if (provider.parallelToolCalls === false - && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) { - body.parallel_tool_calls = false; - } + // Same three provider states as the translated path, and the same defect in the unset one: + // a caller's explicit false was dropped here too (#5211). The native route reads the bit off + // the raw request rather than the parsed options, since nothing projects this body. + const requested = typeof rawBody.parallel_tool_calls === "boolean" ? rawBody.parallel_tool_calls : undefined; + const parallelToolCalls = chatParallelToolCallsWireValue(provider, requested); + if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls; } if (stream) { const callerOptions = rawBody.stream_options !== null diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 8d6e8bc63d7..f077f92c2c5 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -181,8 +181,9 @@ export function createRegisteredAdapter( const definition = getAdapterDefinition(provider.adapter); if (!definition) throw new Error(`Unknown adapter: ${provider.adapter}`); const adapter = definition.create(provider, context); - if (effectiveAdapterContract(provider.adapter).wire !== "openai-responses") { - withInputMediaGuard(adapter); + const wire = effectiveAdapterContract(provider.adapter).wire; + if (wire !== "openai-responses") { + withInputMediaGuard(adapter, wire); } const buildRequest = adapter.buildRequest.bind(adapter); adapter.buildRequest = (parsed, incoming) => { diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index b12761c8e4e..1669207c447 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -92,11 +92,32 @@ function userContentToBlocks(content: unknown): Rec[] { continue; } const videoUrl = videoUrlFromPart(raw); - if (videoUrl) blocks.push({ type: "input_video", video_url: videoUrl }); + if (videoUrl) { + blocks.push({ type: "input_video", video_url: videoUrl }); + continue; + } + const file = fileFromPart(raw); + if (file) blocks.push(file); } return blocks; } +/** + * A Chat Completions `file` part carrying inline bytes, as the Responses `input_file` block. + * + * Nothing here recognized the shape, so the part reached the end of the loop with no branch and + * was dropped in silence (#5212). A part with no inline bytes is still not translatable and is + * left to the untranslated-media refusal, which runs before this loop. + */ +function fileFromPart(part: Rec): Rec | null { + if (part.type !== "file" && part.type !== "input_file") return null; + const file = isRec(part.file) ? part.file : part; + const fileData = file.file_data; + if (typeof fileData !== "string" || fileData.length === 0) return null; + const filename = typeof file.filename === "string" && file.filename.length > 0 ? file.filename : undefined; + return { type: "input_file", file_data: fileData, ...(filename ? { filename } : {}) }; +} + /** * The assistant's prior thinking, as plaintext, from either Chat spelling. * @@ -218,11 +239,67 @@ function toolChoiceToResponses(choice: unknown, body: Rec): void { body.tool_choice = { type: "function", name }; return; } + if (choice.type === "allowed_tools") { + body.tool_choice = allowedToolsChoiceToResponses(choice); + return; + } if (isRec(choice.function) && typeof choice.function.name === "string") { body.tool_choice = { type: "function", name: choice.function.name }; } } +/** + * Chat Completions nests the subset under `allowed_tools`, Responses carries `mode`/`tools` + * on the choice itself, and each entry names its tool under a member keyed by its own type + * (`{"type":"function","function":{"name"}}`) rather than a flat `name`. Neither level lines up + * with `mapToolChoice`, so an unflattened choice fell past every branch and the caller's subset + * was dropped while the full catalogue was still advertised (#5211). + * + * An entry nobody can name is refused rather than skipped: dropping one widens the very subset + * the caller sent this field to narrow. + */ +function allowedToolsChoiceToResponses(choice: Rec): Rec { + const spec = isRec(choice.allowed_tools) ? choice.allowed_tools : choice; + if (!Array.isArray(spec.tools) || spec.tools.length === 0) { + throw new ChatCompletionsRequestError("tool_choice.allowed_tools requires a non-empty tools array"); + } + return { + type: "allowed_tools", + mode: spec.mode === "required" ? "required" : "auto", + tools: spec.tools.map(allowedToolEntryToResponses), + }; +} + +/** Hosted entries are named by their type alone; a function or custom entry must carry a name. */ +const HOSTED_ALLOWED_TOOL_TYPES = new Set([ + "web_search", + "web_search_preview", + "image_generation", + "image_gen", + "tool_search", +]); +const NAMED_ALLOWED_TOOL_TYPES = new Set(["function", "custom"]); + +function allowedToolEntryToResponses(raw: unknown): Rec { + if (!isRec(raw)) { + throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries must be objects"); + } + const type = typeof raw.type === "string" && raw.type.length > 0 ? raw.type : "function"; + if (!NAMED_ALLOWED_TOOL_TYPES.has(type) && !HOSTED_ALLOWED_TOOL_TYPES.has(type)) { + // An unknown selector kind is not a narrower subset, it is a subset nobody can evaluate. + throw new ChatCompletionsRequestError(`unsupported tool_choice.allowed_tools.tools entry type: ${type}`); + } + const nested = isRec(raw[type]) ? raw[type] as Rec : undefined; + const name = typeof raw.name === "string" && raw.name.length > 0 + ? raw.name + : nested !== undefined && typeof nested.name === "string" && nested.name.length > 0 + ? nested.name + : undefined; + if (name !== undefined) return { type, name }; + if (HOSTED_ALLOWED_TOOL_TYPES.has(type)) return { type }; + throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries require a name"); +} + function responseFormatToText(format: unknown): Rec | undefined { if (format === undefined) return undefined; if (!isRec(format)) throw new ChatCompletionsRequestError("response_format must be an object"); diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 0b59a93073a..fbfe6ed503e 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -35,6 +35,12 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined { // call, so carry the source intent instead of the destination default. A // non-boolean value is not a valid Anthropic opt-in and must not become one. strict: typeof raw.strict === "boolean" ? raw.strict : false, + // Anthropic restricts who may invoke a tool through allowed_callers. Nothing read it, + // so the restriction never reached the internal tool and every destination rebuilt the + // declaration without it while the request still succeeded (#5210). + ...(Array.isArray(raw.allowed_callers) + ? { allowed_callers: raw.allowed_callers.filter((c): c is string => typeof c === "string") } + : {}), }); continue; } diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index e3664bf01c2..2938c9b2a9d 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -19,6 +19,7 @@ import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, f import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; +import { inlineDocumentMarker } from "../responses/inline-document"; import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; @@ -41,6 +42,26 @@ function imageBlockToInputImage(block: Rec): Rec | null { return null; } +function documentTitle(block: Rec): string | undefined { + return typeof block.title === "string" && block.title.length > 0 ? block.title : undefined; +} + +/** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */ +function documentBlockToInputFile(block: Rec): Rec | null { + const source = block.source; + if (!isRec(source) || source.type !== "base64") return null; + const mediaType = typeof source.media_type === "string" && source.media_type.length > 0 + ? source.media_type + : "application/octet-stream"; + if (typeof source.data !== "string" || source.data.length === 0) return null; + const title = documentTitle(block); + return { + type: "input_file", + file_data: `data:${mediaType};base64,${source.data}`, + ...(title !== undefined ? { filename: title } : {}), + }; +} + function toolResultOutput(block: Rec): string | Rec[] { const isError = block.is_error === true; const content = block.content; @@ -55,9 +76,11 @@ function toolResultOutput(block: Rec): string | Rec[] { const img = imageBlockToInputImage(item); if (img) out.push(img); } else if (item.type === "document") { - // Same marker as the user-message document case below: the model should see the - // attachment happened instead of an empty tool output. - out.push({ type: "input_text", text: `[document${typeof item.title === "string" ? `: ${item.title}` : ""}]` }); + // Tool output has no structured document carrier on this route — the Responses tool + // output vocabulary has no input_file block, and every adapter's tool-result path + // flattens to text — so this keeps the #939 marker. The user-message branch below is + // where bytes survive. Recorded as the remaining half of #5212. + out.push({ type: "input_text", text: inlineDocumentMarker(documentTitle(item)) }); } } if (isError) out.unshift({ type: "input_text", text: "[tool error]" }); @@ -208,9 +231,12 @@ function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionC break; } case "document": - // No Responses equivalent for raw document blocks; surface the title so the - // model at least sees the attachment happened. - pending.push({ type: "input_text", text: `[document${typeof raw.title === "string" ? `: ${raw.title}` : ""}]` }); + // A base64 document now rides the Responses input_file block, so a target with a + // counterpart receives the bytes instead of a sentence about them (#5212). Every other + // source is a reference this route cannot dereference, and keeps the marker #939 + // introduced — which is also what a target with no document representation still sees. + pending.push(documentBlockToInputFile(raw) + ?? { type: "input_text", text: inlineDocumentMarker(documentTitle(raw)) }); break; default: break; // thinking/redacted_thinking never appear in user messages; ignore unknowns diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 93edd25d0fc..abf7e5b0bb7 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -258,6 +258,7 @@ export const providerConfigSchema = z.object({ requiresAdjacentResponsesToolResults: z.boolean().optional(), requiresPairedResponsesToolResults: z.boolean().optional(), annotateEmptyToolOutputs: z.boolean().optional(), + foldDeveloperRoleToSystem: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), diff --git a/src/responses/inline-document.ts b/src/responses/inline-document.ts new file mode 100644 index 00000000000..208d3f84d02 --- /dev/null +++ b/src/responses/inline-document.ts @@ -0,0 +1,58 @@ +import type { OcxDocumentContent } from "../types"; + +const DATA_URL = /^data:([^;,]+)((?:;[^;,]*)*),(.*)$/s; +const BASE64_PAYLOAD = /^[A-Za-z0-9+/]+={0,2}$/; + +/** The one marker vocabulary for an attached document, derived from what the part knows. */ +export function inlineDocumentMarker(filename: string | undefined): string { + return filename !== undefined && filename.length > 0 ? `[document: ${filename}]` : "[document]"; +} + +/** + * An inline document part from a `data:` URL, or `undefined` when there are no usable bytes. + * + * A reference with no payload — a `file_id`, a bare filename — is deliberately not a document: + * there is nothing to carry, and minting a part for it would claim an attachment the request + * never contained. Those keep the marker path they already had. + */ +export function inlineDocumentFromDataUrl( + fileData: string | undefined, + filename: string | undefined, +): OcxDocumentContent | undefined { + if (typeof fileData !== "string" || fileData.length === 0) return undefined; + const match = DATA_URL.exec(fileData); + if (!match) return undefined; + const mediaType = match[1]!; + const payload = match[3]!; + if (!hasBase64Parameter(match[2] ?? "") || !BASE64_PAYLOAD.test(payload)) return undefined; + return { + type: "document", + text: inlineDocumentMarker(filename), + mediaType, + data: payload, + ...(filename !== undefined && filename.length > 0 ? { filename } : {}), + }; +} + +/** The `data:` URL spelling of a document part, for wires whose counterpart takes one. */ +export function inlineDocumentDataUrl(part: OcxDocumentContent): string { + return `data:${part.mediaType};base64,${part.data}`; +} + +/** + * Whether this string is a `data:` URL this module would actually decode. + * + * The media-type scanner and this parser have to agree exactly. A looser scanner would exempt an + * attachment from the untranslated-media refusal that the parser then reduces to a marker, which + * is the silent drop the refusal exists to prevent. `;base64` must be a whole parameter token, + * not a substring of one: `;notbase64,` and `;x=base64,` are not base64 payloads. + */ +export function isInlineDocumentDataUrl(value: unknown): boolean { + if (typeof value !== "string" || value.length === 0) return false; + const match = DATA_URL.exec(value); + return match !== null && hasBase64Parameter(match[2] ?? "") && BASE64_PAYLOAD.test(match[3]!); +} + +function hasBase64Parameter(parameters: string): boolean { + return parameters.split(";").includes("base64"); +} diff --git a/src/responses/input-media.ts b/src/responses/input-media.ts index 43f8db583ec..be4909198e9 100644 --- a/src/responses/input-media.ts +++ b/src/responses/input-media.ts @@ -1,3 +1,5 @@ +import { isInlineDocumentDataUrl } from "./inline-document"; + /** Input kinds for which the normalized request has no lossless content carrier. */ export type UntranslatedInputMedia = "audio" | "file"; @@ -7,20 +9,47 @@ function isRecord(value: unknown): value is RecordValue { return value !== null && typeof value === "object" && !Array.isArray(value); } -function mediaKind(value: unknown): UntranslatedInputMedia | undefined { +/** + * Whether the position being scanned has a converter that builds a document part. + * + * Only user content does. A tool output, a system or assistant message, and a Chat `developer` + * message are all flattened to text by their converters, so exempting an attachment there would + * turn today's explicit refusal into the silent drop this scanner exists to prevent. + */ +type CarrierPosition = "user-content" | "flattened"; + +function mediaKind(value: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined { if (!isRecord(value)) return undefined; if (value.type === "input_audio" || value.type === "audio") return "audio"; - if (value.type === "input_file" || value.type === "file" || value.type === "document") return "file"; + if (value.type === "input_file" || value.type === "file" || value.type === "document") { + // A document that carries its own bytes has a lossless carrier in user content, so refusing + // it there would reject the very request #5212 exists to preserve. A reference with no + // payload has none anywhere: translated adapters cannot dereference a file_id or a remote + // source. + return position === "user-content" && carriesInlineDocumentBytes(value) ? undefined : "file"; + } // A file-id-only image is not pixels: translated adapters cannot dereference it. if (value.type === "input_image" && typeof value.file_id === "string" && value.file_id.length > 0 && !(typeof value.image_url === "string" && value.image_url.length > 0)) return "file"; return undefined; } -function contentMedia(content: unknown): UntranslatedInputMedia | undefined { +/** Presence of a base64 payload only. No payload is read, decoded, copied or returned. */ +function carriesInlineDocumentBytes(value: RecordValue): boolean { + if (isInlineDocumentDataUrl(value.file_data)) return true; + // Chat Completions nests the payload under `file`. + if (isRecord(value.file) && isInlineDocumentDataUrl(value.file.file_data)) return true; + // Anthropic nests it under a base64 `source`. + return isRecord(value.source) + && value.source.type === "base64" + && typeof value.source.data === "string" + && value.source.data.length > 0; +} + +function contentMedia(content: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined { if (!Array.isArray(content)) return undefined; for (const part of content) { - const kind = mediaKind(part); + const kind = mediaKind(part, position); if (kind) return kind; } return undefined; @@ -35,13 +64,16 @@ export function untranslatedResponsesInputMedia(body: unknown): UntranslatedInpu if (!isRecord(body) || !Array.isArray(body.input)) return undefined; for (const item of body.input) { if (!isRecord(item)) continue; - const direct = mediaKind(item); + const direct = mediaKind(item, "flattened"); if (direct) return direct; if (item.type === "function_call_output" || item.type === "custom_tool_call_output") { - const kind = contentMedia(item.output); + const kind = contentMedia(item.output, "flattened"); if (kind) return kind; } else if (item.type === "message" || item.type === undefined) { - const kind = contentMedia(item.content); + // `inputContentParts` runs for user and developer messages only; a system message is + // flattened to text by the parser. + const role = item.role; + const kind = contentMedia(item.content, role === "user" || role === "developer" ? "user-content" : "flattened"); if (kind) return kind; } } @@ -53,7 +85,9 @@ export function untranslatedChatInputMedia(body: unknown): UntranslatedInputMedi if (!isRecord(body) || !Array.isArray(body.messages)) return undefined; for (const message of body.messages) { if (!isRecord(message)) continue; - const kind = contentMedia(message.content); + // Only the `user` branch of the Chat projection builds content blocks. `system`, + // `developer`, `assistant` and `tool` all reduce their content to a string. + const kind = contentMedia(message.content, message.role === "user" ? "user-content" : "flattened"); if (kind) return kind; } return undefined; diff --git a/src/responses/parser-content.ts b/src/responses/parser-content.ts index 7675a42f7e8..00b174320ca 100644 --- a/src/responses/parser-content.ts +++ b/src/responses/parser-content.ts @@ -1,4 +1,5 @@ import type { OcxContentPart, OcxTextContent } from "../types"; +import { inlineDocumentFromDataUrl } from "./inline-document"; export function isObj(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -90,8 +91,13 @@ export function inputContentParts(blocks: unknown): string | OcxContentPart[] { if (fileId) { parts.push({ type: "text", text: `[file: ${fileId}]` }); } else if (fileData) { - // Inline file_data is often large base64. Preserve only its presence and name, never bytes. - parts.push({ type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }); + // Inline bytes used to be reduced to a name here, which meant no adapter could forward + // the attachment even to a target that has a representation for it, and a title-only + // forward came back as a confident answer about a document the model never saw (#5212). + // The marker survives on the part for every wire that still cannot carry one. Bytes with + // no declared media type are not decodable into a document and keep the marker they had. + const document = inlineDocumentFromDataUrl(fileData, filename); + parts.push(document ?? { type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }); } // A bare filename is not a file resource in the Responses schema, so omit it rather than // fabricating a "[file: ...]" marker for an attachment that was never sent. diff --git a/src/responses/parser-tools.ts b/src/responses/parser-tools.ts index 8812b0bbe66..763b061c8f6 100644 --- a/src/responses/parser-tools.ts +++ b/src/responses/parser-tools.ts @@ -61,6 +61,9 @@ export function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined parameters: normalizeParameters(t.parameters), }; if (t.strict !== undefined) tool.strict = t.strict as boolean; + if (Array.isArray(t.allowed_callers)) { + tool.allowedCallers = (t.allowed_callers as unknown[]).filter((c): c is string => typeof c === "string"); + } if (namespace) tool.namespace = namespace; out.push(tool); }; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 396f2170b2e..51bb36f5245 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -245,7 +245,9 @@ export function parseRequest( case "system": { pendingReasoning.length = 0; const text = inputContentParts(msg.content); - const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" ? p.text : "")).join(""); + const flat = typeof text === "string" + ? text + : text.map(p => (p.type === "text" || p.type === "document" ? p.text : "")).join(""); if (flat.length > 0) systemPrompt.push(flat); break; } diff --git a/src/responses/schema.ts b/src/responses/schema.ts index fced1a9e6e2..556bc9fd978 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -122,6 +122,9 @@ export const toolSchema = z.object({ description: z.string().optional(), parameters: z.record(z.string(), z.unknown()).optional(), strict: z.boolean().optional(), + // Unknown keys are stripped here, so a field the parser is expected to read has to be + // declared: an undeclared allowed_callers never reached buildTools at all (#5210). + allowed_callers: z.array(z.string()).optional(), }); const builtinToolSchema = z.object({ type: z.string() }).loose(); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c8f53c861ae..e5228d8272c 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1024,6 +1024,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { pinParallelToolCallsFalse: "editor", terminalContinuationGuard: "editor", openaiChatEofTolerance: "editor", + foldDeveloperRoleToSystem: "editor", promptCacheKey: "editor", chatServiceTier: "editor", responsesItemIdRepair: "editor", diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index ca11fd60eb5..950a3d90505 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -85,9 +85,19 @@ function imageTokens(imageUrl: string): number { function contentPartTokens(part: OcxContentPart, modelId: string): number { if (part.type === "image") return imageTokens(part.imageUrl); if (part.type === "video") return imageTokens(part.videoUrl); + // An inline document is a base64 payload, not a sentence: estimating it from its marker would + // admit a request whose real input is orders of magnitude larger. Counted arithmetically — + // rebuilding the data URL here would materialize a second request-sized string just to measure it. + if (part.type === "document") return base64PayloadTokens(part.data); return estimateTokens(part.text, modelId); } +function base64PayloadTokens(base64: string): number { + if (base64.length === 0) return 0; + const decoded = Math.floor((base64.length * 3) / 4); + return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN)); +} + function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number { if (typeof content === "string") return estimateTokens(content, modelId); let total = 0; diff --git a/src/types.ts b/src/types.ts index 93390ce6063..747fc17c756 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export { isAllowedToolChoice, toolChoiceToolPredicate, declaresCodeModeExec, + toolRestrictsCallers, NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES, } from "./types/tools"; @@ -43,6 +44,7 @@ export type { OcxToolResultMessage, OcxTextContent, OcxImageContent, + OcxDocumentContent, OcxContentPart, OcxThinkingContent, OcxToolCall, diff --git a/src/types/provider.ts b/src/types/provider.ts index e67041ee5dd..bbc3d6b5c35 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -816,6 +816,19 @@ export interface OcxProviderConfig { * incomplete JSON, missing arguments, and empty streams remain truncation errors. */ openaiChatEofTolerance?: boolean; + /** + * Opt-in: fold a `developer` message into a `system` message instead of forwarding the role. + * + * `developer` is part of the Chat Completions message role set, so forwarding it is the + * default. The role used to be decided by testing the base URL host against + * `api.openai.com`, which assumed every OpenAI-compatible gateway rejects a standard role + * until proven otherwise — including gateways that proxy OpenAI itself — and quietly gave the + * instruction `system` precedence instead (#5213). This flag exists for a destination that + * genuinely rejects the role, so the conversion is a recorded decision about that destination + * rather than an inference from its hostname. Position is unaffected either way: the message + * keeps its slot in the conversation. + */ + foldDeveloperRoleToSystem?: boolean; /** * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown diff --git a/src/types/request.ts b/src/types/request.ts index 73bc671c8c2..7a73eaa5987 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -210,8 +210,34 @@ export interface OcxVideoContent { videoUrl: string; } -/** A user/developer message content part: text or native media. */ -export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent; +/** + * An attached document carried as bytes rather than as a description of itself. + * + * Both inbound parsers used to reduce an attachment to a title before any adapter ran, so no + * adapter could forward one even to a target that has a representation for it, and the caller + * could not tell "the model read the document" from "the model was told a document existed" + * (#5212). + * + * `text` is that marker, derived once from what the part knows and kept on the part itself. + * Every text-only consumer in the tree reaches a `.text` fallback for a part it does not + * recognize, so carrying it here means a wire with no document representation still states the + * attachment instead of emitting `undefined` or a mislabelled `[video]`. Only the wires that + * have a counterpart read `data`. + */ +export interface OcxDocumentContent { + type: "document"; + /** `[document: name]` marker, for every wire with no document representation. */ + text: string; + /** IANA media type of the payload, for example `application/pdf`. */ + mediaType: string; + /** Base64 payload with no `data:` prefix. */ + data: string; + /** The document's own name: an Anthropic document title or a Chat file part's filename. */ + filename?: string; +} + +/** A user/developer message content part: text, native media, or an attached document. */ +export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent | OcxDocumentContent; export interface OcxThinkingContent { type: "thinking"; diff --git a/src/types/tools.ts b/src/types/tools.ts index 83decd85b14..70d777d7491 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -3,6 +3,12 @@ export interface OcxTool { description: string; parameters: Record; strict?: boolean; + /** + * Anthropic `tools[*].allowed_callers`: which callers may invoke this tool. Carried rather + * than diagnosed, because rebuilding the declaration without it hands the model a tool the + * caller had restricted and returns a normal response (#5210). + */ + allowedCallers?: string[]; /** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */ namespace?: string; /** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */ @@ -31,6 +37,19 @@ export function namespacedToolName(namespace: string | undefined, name: string): return namespace ? `${namespace}__${name}` : name; } +/** + * Whether a declaration actually narrows who may call the tool. + * + * `["direct"]` is the state every unrestricted tool is already in, so treating it as a + * restriction would refuse ordinary traffic. Mirrors the `caller_mode` predicate in + * src/claude/compatibility.ts, which draws the same line. + */ +export function toolRestrictsCallers(tool: Pick): boolean { + const callers = tool.allowedCallers; + if (callers === undefined) return false; + return !(callers.length === 1 && callers[0] === "direct"); +} + /** * Dotted alias of a namespaced tool's wire name. Some routed providers (observed: muse-spark * via opencode-go) echo a namespaced tool call as "." instead of the flattened diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 28a464e3e03..a21be43a466 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -16,8 +16,9 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun Runtime adapter construction has one authority: `src/adapters/registry.ts`. -The OpenCode Go [chronological instruction exception](../providers/chat-compat.md#opencode-go-chronological-instructions) -uses the provider registry's destination identity inside the Chat adapter; it adds no adapter factory. +[Chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +is now uniform across destinations, so the Chat adapter no longer consults the provider registry's +destination identity for it; it adds no adapter factory. `src/server/adapter-resolve.ts` may resolve a provider/model onto an adapter id, but it does not maintain a second adapter factory inventory. The selected persisted/configured adapter id remains an untrusted string until the registry lookup succeeds. Unknown ids fail with the existing `Unknown adapter: ` error instead of widening configuration types around a closed compile-time union. @@ -196,8 +197,18 @@ variant; unrelated model families retain their existing suffix precedence. `src/responses/input-media.ts` inspects actual content blocks and typed tool-output arrays without parsing text or function arguments, copying attachment payloads, resolving file IDs, -or fetching URLs. Audio, files/documents and file-ID-only images have no lossless normalized -carrier. The scanner returns only an input-kind name, never client content. +or fetching URLs. Audio and file-ID-only images have no lossless normalized carrier, and +neither does a file or document reference that carries no bytes. The scanner returns only an +input-kind name, never client content. + +A document that carries its own base64 bytes is the one exception, and only in user or +developer message content: `src/responses/inline-document.ts` decodes it into the +`OcxDocumentContent` part, which the Anthropic, OpenAI Chat and Gemini wires emit as a native +document, file part and `inline_data` respectively. Every other position — tool output, system +and assistant content — is still refused, because those converters reduce their content to text +and exempting them would restore the silent drop the scanner exists to prevent. The scanner and +the decoder share one predicate so a request cannot be exempted here and reduced to a marker +there. `src/adapters/input-media-guard.ts` guards adapters created by the registry after effective wire selection. A translated `buildRequest` refuses these inputs through the existing 400 diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index e6171845efa..90dacb24366 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -51,9 +51,9 @@ separate. Coverage lives in `tests/server/audio-client.test.ts`, `tests/server/audio-dictation.test.ts` and `tests/server/live-call-bindings.test.ts`. Translated Claude timeline reminders use the Chat adapter's -[OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) -on its exact supported route. This is separate from trailing-notice stabilization -and from native Chat message passthrough. +[chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +on every destination. This is separate from trailing-notice stabilization and from +native Chat message passthrough. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). @@ -334,7 +334,11 @@ default and require an explicit `thinking:{type:"disabled"}` to stop. The native Chat path retains provider-native file/audio blocks. When a request instead needs Chat-to-Responses projection, `src/chat/inbound.ts` rejects recognized audio/file content -before it can become empty text, regardless of message role. Legacy `function`-role images +before it can become empty text. The one exception is a `file` part carrying inline base64 +bytes in a `user` message: that projection builds an `input_file` block and the bytes survive +to any wire with a counterpart. The same part in a `system`, `developer`, `assistant` or +`tool` message is still refused, because those branches flatten their content to a string. +Legacy `function`-role images also return an explicit error; their call/result pairing is not implemented by this projection. Modern `tool` images continue through the existing following-user carrier. These errors state an OpenCodex conversion limit, not a provider capability claim. Final Responses-to-adapter diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 0121fb76e40..de68ef9f7ae 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -15,9 +15,10 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. | -| `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. | +| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. | +| `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | +| `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. | diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 6fe286bd76d..cf601215ed8 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -9,18 +9,27 @@ Native Codex Spark-specific request exceptions are absent. General Lite and name remain shared [Responses compatibility](../transports/responses.md#responses-httpsse), including other providers whose models happen to share a name fragment. -## OpenCode Go chronological instructions - -For the registry-recognized OpenCode Go Chat destination and exact model -`deepseek-v4.1-flash`, `src/adapters/openai-chat.ts` keeps text-only timeline -developer messages in place as system messages. Appending a reminder therefore -does not hoist new text into the leading system prompt and rewrite the existing -serialized message prefix. Pending tool results still precede deferred reminders. -The base system prompt, vision conversion and native OpenAI developer roles retain -their existing behavior; other Chat destinations and models retain leading-system -folding. This is independent of the Claude trailing-notice stabilization option -and does not guarantee upstream cache hits. Regression coverage is in -`tests/adapters/openai/openai-chat-system-order.test.ts`. +## Chronological in-conversation instructions + +`src/adapters/openai-chat/messages.ts` keeps a text-only timeline developer message in the +slot it arrived in, on every Chat destination and model. Appending a reminder therefore does +not hoist new text into the leading system prompt and rewrite the existing serialized message +prefix, and a mid-conversation instruction no longer moves ahead of the turns it was written +to follow. Pending tool results still precede deferred reminders. This was previously scoped +to the registry-recognized OpenCode Go destination and the exact model +`deepseek-v4.1-flash`, which made prompt-prefix stability read as a property of that one +destination. The base system prompt, vision conversion and native OpenAI developer roles +retain their existing behavior. This is independent of the Claude trailing-notice +stabilization option and does not guarantee upstream cache hits. Regression coverage is in +`tests/adapters/openai/openai-chat-system-order.test.ts` and +`tests/adapters/openai/openai-chat-developer-position.test.ts`. + +The role that slot carries is a separate decision. `developer` is part of the Chat Completions +message role set and is forwarded as itself on every destination. A destination that genuinely +rejects the role sets `foldDeveloperRoleToSystem`, which converts it in place and still never +moves the message. The role was previously decided by testing the base URL host against +`api.openai.com`, so every OpenAI-compatible gateway was assumed not to support a standard role +until proven otherwise, and the instruction silently lost `developer` precedence. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. @@ -418,8 +427,9 @@ The final registered adapter also checks the original input under the [untranslated-media contract](../adapters/registry.md#untranslated-input-media). Audio/file attachments cannot succeed merely because the normalized representation retained a text marker: translated adapters refuse them, while native Responses retains the original body. -Chat conversion rejects recognized audio/file parts before projection; the native Chat wire -is unchanged. No audio/file transport or automatic URL fetch is added, and no client filename, +Chat conversion rejects recognized audio/file parts before projection, except a user-content +file part whose inline base64 bytes now have a lossless carrier; the native Chat wire is +unchanged. No audio transport and no automatic URL fetch is added, and no client filename, payload, URL or metadata is included in the new error messages. The shared coding-agent projection (CodeBuddy, Qoder) carries tool-result images as diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 3f047315203..7252130843a 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -8,7 +8,7 @@ namespace handling retain their provider contract; the bounded native scope live [the shared catalog](../catalog.md#shared-catalog). Cursor's direct adapter does not enter the OpenAI Chat serializer's -[OpenCode Go instruction ordering](chat-compat.md#opencode-go-chronological-instructions). +[chronological instruction ordering](chat-compat.md#chronological-in-conversation-instructions). Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/runtime.md b/structure/runtime.md index 5c19599c4f5..9d7316cfc4e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -30,9 +30,10 @@ OAuth refresh coordination follows the [refresh-lock identity contract](catalog. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](providers/cursor.md#cursor-native-exec). -Chat request serialization owns the destination-scoped -[OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); -it requires no runtime lifecycle change or new configuration option. +Chat request serialization owns +[chronological instruction ordering](providers/chat-compat.md#chronological-in-conversation-instructions) +and the developer wire role; it requires no runtime lifecycle change, and its one +configuration option is a per-provider role opt-out. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index e81ec55dc57..ddfe0a022c2 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -12,8 +12,9 @@ The existing Responses transport is divided by responsibility in the The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). -The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) -changes translated message placement only; endpoint selection and transport stay with their existing owners. +The Chat adapter's [chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +changes translated message placement and the developer wire role only; endpoint selection and transport +stay with their existing owners. Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). diff --git a/tests/adapters/adapter-input-media-guard.test.ts b/tests/adapters/adapter-input-media-guard.test.ts index c63c137d52b..ff98aa347d8 100644 --- a/tests/adapters/adapter-input-media-guard.test.ts +++ b/tests/adapters/adapter-input-media-guard.test.ts @@ -8,13 +8,17 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../sr import { createTestTranslatorBudget, withTestTranslatorBudget } from "../helpers/translator-budget"; const AUDIO = { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }; -const FILE = { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }; +// A reference this route cannot dereference: there are no bytes to carry anywhere. +const FILE = { type: "input_file", filename: "private.pdf", file_id: "file-private" }; +// The same attachment with its bytes. User content has a carrier for it (#5212); no other +// position does, because every other converter reduces its content to text. +const INLINE_FILE = { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }; function request(content: unknown[]): OcxParsedRequest { return parseRequest({ model: "test-model", input: [{ type: "message", role: "user", content }] }); } -function fakeAdapter() { +function fakeAdapter(wire: "openai-chat" | "cursor" = "openai-chat") { const seen = { builds: 0, runs: 0, terminals: 0 }; const adapter: ProviderAdapter = { name: "stub", @@ -26,7 +30,7 @@ function fakeAdapter() { async runTurn(_parsed, _incoming, emit) { seen.runs++; emit({ type: "done", endTurn: true }); }, localTerminal() { seen.terminals++; return { reason: "already answered" }; }, }; - return { adapter: withInputMediaGuard(adapter), seen }; + return { adapter: withInputMediaGuard(adapter, wire), seen }; } describe("typed input media inspection", () => { @@ -39,6 +43,43 @@ describe("typed input media inspection", () => { for (const type of ["function_call_output", "custom_tool_call_output"]) { expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [AUDIO] }] })).toBe("audio"); expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [FILE] }] })).toBe("file"); + // Bytes do not help here: the tool-output converter flattens its content to text. + expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [INLINE_FILE] }] })).toBe("file"); + } + }); + + test("an inline document is permitted only where a converter carries it", () => { + expect(untranslatedResponsesInputMedia(request([INLINE_FILE])._rawBody)).toBeUndefined(); + for (const role of ["developer", "user"]) { + expect(untranslatedResponsesInputMedia({ input: [{ type: "message", role, content: [INLINE_FILE] }] })) + .toBeUndefined(); + } + for (const role of ["system", "assistant"]) { + expect(untranslatedResponsesInputMedia({ input: [{ type: "message", role, content: [INLINE_FILE] }] })) + .toBe("file"); + } + }); + + test("a document still has to reach a wire that can hold its bytes", () => { + const incoming = { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }; + const carrier = fakeAdapter("openai-chat"); + carrier.adapter.buildRequest(request([INLINE_FILE]), incoming); + expect(carrier.seen.builds).toBe(1); + + // Cursor rebuilds user content as text, so admitting the bytes there would put the request + // upstream with only the marker and return a normal completion. + const nonCarrier = fakeAdapter("cursor"); + let failure: unknown; + try { nonCarrier.adapter.buildRequest(request([INLINE_FILE]), incoming); } catch (error) { failure = error; } + expect((failure as Error).message).toContain("OpenCodex cannot translate document input"); + expect((failure as Error).message).not.toContain("private.pdf"); + expect(nonCarrier.seen.builds).toBe(0); + }); + + test("a base64 look-alike parameter is not an inline payload", () => { + for (const fileData of ["data:text/plain;notbase64,abc", "data:text/plain;x=base64,abc", "data:text/plain,abc"]) { + expect(untranslatedResponsesInputMedia(request([{ type: "input_file", file_data: fileData }])._rawBody)) + .toBe("file"); } }); diff --git a/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts new file mode 100644 index 00000000000..841fe1fa933 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import { createRegisteredAdapter } from "../../../src/adapters/registry"; +import { anthropicToResponsesBody } from "../../../src/claude/inbound"; +import { parseRequest } from "../../../src/responses/parser"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5210. `strict` and `allowed_callers` are declaration fields Anthropic defines, and the + * Messages-to-Messages route rebuilt every tool from name, description and input_schema alone. + * The request succeeded, so a caller had no way to learn that the schema was no longer enforced + * or that the tool had been offered to a caller it was fenced off from. Each case below reads + * the request the adapter actually sends. + */ + +const anthropicProvider = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "sk-x", + authMode: "apiKey", +} as unknown as OcxProviderConfig; + +function claudeTool(extra: Record): Record { + return { + name: "tool_a", + description: "Controlled tool.", + input_schema: { type: "object", properties: {} }, + ...extra, + }; +} + +function parsedFromClaude(tool: Record): OcxParsedRequest { + return parseRequest(anthropicToResponsesBody({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: "Call the tool." }], + tools: [tool], + })); +} + +async function anthropicTools(tool: Record): Promise>> { + const { body } = await createAnthropicAdapter(anthropicProvider).buildRequest(parsedFromClaude(tool)); + return (JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + tools: Array>; + }).tools; +} + +describe("anthropic tool declarations carry their caller-supplied constraints", () => { + test("an explicit strict:true survives the round trip", async () => { + const [tool] = await anthropicTools(claudeTool({ strict: true })); + expect(tool.strict).toBe(true); + expect(tool.name).toBe("tool_a"); + expect(tool.input_schema).toEqual({ type: "object", properties: {} }); + }); + + test("an unstated strict stays absent rather than becoming an opt-out", async () => { + const [tool] = await anthropicTools(claudeTool({})); + expect(tool).not.toHaveProperty("strict"); + const [explicitFalse] = await anthropicTools(claudeTool({ strict: false })); + expect(explicitFalse).not.toHaveProperty("strict"); + }); + + test("allowed_callers reaches the upstream instead of being rebuilt away", async () => { + const [tool] = await anthropicTools(claudeTool({ allowed_callers: ["code_execution_20260120"] })); + expect(tool.allowed_callers).toEqual(["code_execution_20260120"]); + }); + + test("a tool without allowed_callers gains no key", async () => { + const [tool] = await anthropicTools(claudeTool({})); + expect(tool).not.toHaveProperty("allowed_callers"); + }); +}); + +describe("wires without an allowed_callers counterpart refuse rather than widen", () => { + const restricted = claudeTool({ allowed_callers: ["code_execution_20260120"] }); + const unrestricted = claudeTool({ allowed_callers: ["direct"] }); + const incoming = { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }; + + // The refusal is default-deny at the single guard every registered adapter passes through, so + // a wire that never learned about the field cannot quietly rebuild the declaration without it. + test.each([ + ["openai-chat", { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k" }], + ["google", { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }], + ["cursor", { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "k" }], + ["devin", { adapter: "devin", baseUrl: "https://api.devin.ai", apiKey: "k" }], + ["ollama-native", { adapter: "ollama-native", baseUrl: "http://127.0.0.1:11434", keyOptional: true }], + ])("the %s wire refuses a caller-restricted declaration", async (_name, config) => { + const adapter = createRegisteredAdapter(config as unknown as OcxProviderConfig); + await expect(Promise.resolve().then(() => adapter.buildRequest(parsedFromClaude(restricted), incoming))) + .rejects.toThrow(/cannot express tools\[\]\.allowed_callers/); + }); + + test("the Anthropic wire is the one that carries it", async () => { + const adapter = createRegisteredAdapter(anthropicProvider); + const { body } = await adapter.buildRequest(parsedFromClaude(restricted), incoming); + const sent = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + tools: Array>; + }; + expect(sent.tools[0]!.allowed_callers).toEqual(["code_execution_20260120"]); + }); + + test('the unrestricted ["direct"] default is not treated as a restriction', async () => { + const adapter = createRegisteredAdapter({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", + } as unknown as OcxProviderConfig); + // Registered adapters may wrap buildRequest in a promise; await rather than assume a shape. + const { body } = await adapter.buildRequest(parsedFromClaude(unrestricted), incoming); + const built = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + tools: Array<{ function: { name: string } }>; + }; + expect(built.tools.map(tool => tool.function.name)).toEqual(["tool_a"]); + }); +}); diff --git a/tests/adapters/google/google-strict-tool-validated-mode.test.ts b/tests/adapters/google/google-strict-tool-validated-mode.test.ts new file mode 100644 index 00000000000..f508162ec60 --- /dev/null +++ b/tests/adapters/google/google-strict-tool-validated-mode.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../../../src/adapters/google"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5210 case 3. Gemini expresses schema-enforced function calling as + * `functionCallingConfig.mode: "VALIDATED"`. The mode was plumbed all the way to the wire + * compiler but was only reachable by matching a model name, so a caller that declared strict + * tools got an ordinary AUTO turn and a normal answer. These assertions read the compiled + * request body, which is the only place the difference is visible. + */ + +const provider = { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "key", +} as unknown as OcxProviderConfig; + +const STRICT_TOOLS = [ + { name: "get_weather", description: "", parameters: { type: "object", properties: {} }, strict: true }, + { name: "shot", description: "", parameters: { type: "object", properties: {} } }, +]; +const LOOSE_TOOLS = STRICT_TOOLS.map(({ strict: _strict, ...rest }) => rest); + +async function toolConfig(tools: unknown[], toolChoice?: unknown): Promise { + const parsed = { + modelId: "gemini-3-pro", + stream: false, + options: toolChoice === undefined ? {} : { toolChoice }, + context: { messages: [{ role: "user", content: "hi" }], tools }, + } as unknown as OcxParsedRequest; + const { body } = await createGoogleAdapter(provider).buildRequest(parsed); + return (JSON.parse(body) as Record).toolConfig; +} + +describe("strict tool declarations select Gemini VALIDATED function calling", () => { + test("a strict declaration turns the absent-choice default into VALIDATED", async () => { + expect(await toolConfig(STRICT_TOOLS)).toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + expect(await toolConfig(STRICT_TOOLS, "auto")).toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + }); + + test("an allowed-tools subset in auto mode keeps VALIDATED when the subset is strict", async () => { + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["get_weather"], mode: "auto" })) + .toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + }); + + test("a subset that excludes the strict tool does not claim validation", async () => { + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["shot"], mode: "auto" })).toBeUndefined(); + }); + + test("without a strict declaration the wire is unchanged", async () => { + expect(await toolConfig(LOOSE_TOOLS)).toBeUndefined(); + expect(await toolConfig(LOOSE_TOOLS, "auto")).toBeUndefined(); + expect(await toolConfig(LOOSE_TOOLS, { allowedTools: ["get_weather"], mode: "auto" })).toBeUndefined(); + }); + + test("a stronger caller-chosen mode is never overwritten by VALIDATED", async () => { + expect(await toolConfig(STRICT_TOOLS, "none")).toEqual({ functionCallingConfig: { mode: "NONE" } }); + expect(await toolConfig(STRICT_TOOLS, "required")).toEqual({ functionCallingConfig: { mode: "ANY" } }); + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["get_weather"], mode: "required" })) + .toEqual({ functionCallingConfig: { mode: "ANY" } }); + expect(await toolConfig(STRICT_TOOLS, { name: "get_weather" })) + .toEqual({ functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["get_weather"] } }); + }); + + test("a request with no declared tools gains no toolConfig", async () => { + expect(await toolConfig([])).toBeUndefined(); + }); +}); diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index df5e8afc5b7..41a61c21023 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -90,7 +90,7 @@ function assertWireInvariants(messages: ChatMsg[]): void { } describe("openai-chat dangling tool_calls hardening", () => { - test("T1 incident: developer guidance is hoisted while the real result reattaches to the original call", () => { + test("T1 incident: developer guidance lands after the result while the result reattaches to the original call", () => { const messages = wire([ user("hi"), assistantWithCalls([{ id: "call_x", name: "request_user_input" }]), @@ -100,14 +100,14 @@ describe("openai-chat dangling tool_calls hardening", () => { ]); assertWireInvariants(messages); const roles = messages.map(m => m.role); - expect(messages[0]).toEqual({ role: "system", content: "[injected guidance]" }); - // canonical history order: assistant, tool(real), user; no in-history system barrier + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + // canonical history order: assistant, tool(real), then the barrier in its own slot (#5213) const aIdx = roles.indexOf("assistant"); expect(roles[aIdx + 1]).toBe("tool"); expect(messages[aIdx + 1].tool_call_id).toBe("call_x"); expect(messages[aIdx + 1].content).toBe('{"answers":{}}'); - expect(roles[aIdx + 2]).toBe("user"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[aIdx + 2]).toEqual({ role: "developer", content: "[injected guidance]" }); + expect(roles[aIdx + 3]).toBe("user"); // no synthetic result fabricated for an answered call expect(messages.some(m => typeof m.content === "string" && m.content.includes("no tool result was recorded"))).toBe(false); }); @@ -166,9 +166,9 @@ describe("openai-chat dangling tool_calls hardening", () => { expect(String(synth?.content)).toContain("no tool result was recorded"); const real = block.find(m => m.tool_call_id === "call_2"); expect(real?.content).toBe("img-ok"); - expect(messages[0]).toEqual({ role: "system", content: "barrier while call_1 pending" }); - expect(messages[aIdx + 3].role).toBe("assistant"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + expect(messages[aIdx + 3]).toEqual({ role: "developer", content: "barrier while call_1 pending" }); + expect(messages[aIdx + 4].role).toBe("assistant"); }); test("T6 mismatched result while calls pending: round closes synthetically, then orphan pair", () => { @@ -183,9 +183,9 @@ describe("openai-chat dangling tool_calls hardening", () => { expect(messages[aIdx + 1].role).toBe("tool"); expect(messages[aIdx + 1].tool_call_id).toBe("call_p"); expect(String(messages[aIdx + 1].content)).toContain("no tool result was recorded"); - expect(messages[0]).toEqual({ role: "system", content: "deferred barrier" }); - expect(messages[aIdx + 2].role).toBe("assistant"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + expect(messages[aIdx + 2]).toEqual({ role: "developer", content: "deferred barrier" }); + expect(messages[aIdx + 3].role).toBe("assistant"); const orphanIdx = messages.findIndex((m, i) => i > aIdx && m.role === "assistant"); expect(messages[orphanIdx].tool_calls?.[0].id).toBe("call_unknown"); expect(messages[orphanIdx + 1].tool_call_id).toBe("call_unknown"); diff --git a/tests/adapters/openai/openai-chat-developer-position.test.ts b/tests/adapters/openai/openai-chat-developer-position.test.ts new file mode 100644 index 00000000000..311d109b804 --- /dev/null +++ b/tests/adapters/openai/openai-chat-developer-position.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5213. A `developer` message used to keep its slot only when the provider base URL host was + * exactly `api.openai.com`. Everywhere else its text was appended to the system prompt and the + * message itself was skipped, so an instruction written to apply from the second turn onward + * arrived ahead of the first one. Both shapes return a normal completion, which is why these + * assertions read the serialized request body rather than the response. + */ + +const gateway: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", +}; + +function wireMessages(provider: OcxProviderConfig): Array> { + const parsed = { + modelId: "local-model", + context: { + systemPrompt: ["base instructions"], + messages: [ + { role: "user", content: "First turn.", timestamp: 0 }, + { role: "developer", content: "Answer in exactly one sentence.", timestamp: 0 }, + { role: "user", content: "Second turn.", timestamp: 0 }, + ], + }, + stream: false, + options: {}, + } as unknown as OcxParsedRequest; + const request = createOpenAIChatAdapter(provider).buildRequest(parsed); + return (JSON.parse(request.body) as { messages: Array> }).messages; +} + +describe("developer message placement on the Chat wire", () => { + test("a non-OpenAI gateway keeps the instruction between the two turns", () => { + const messages = wireMessages(gateway); + expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); + expect(messages[0]).toEqual({ role: "system", content: "base instructions" }); + expect(messages[1]).toEqual({ role: "user", content: "First turn." }); + expect(messages[2]).toEqual({ role: "developer", content: "Answer in exactly one sentence." }); + expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); + }); + + test("the leading system block no longer absorbs the instruction", () => { + expect(String(wireMessages(gateway)[0].content)).not.toContain("Answer in exactly one sentence."); + }); + + test("placement does not depend on the destination host", () => { + const hosts = [ + "https://openrouter.ai/api/v1", + "http://localhost:1234/v1", + "https://api.openai.com/v1", + ]; + for (const baseUrl of hosts) { + const messages = wireMessages({ ...gateway, baseUrl }); + expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); + expect(messages[2].content).toBe("Answer in exactly one sentence."); + expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); + } + }); +}); + +describe("developer role on the Chat wire", () => { + test("the role is forwarded as itself rather than inferred from the hostname", () => { + for (const baseUrl of ["https://openrouter.ai/api/v1", "http://localhost:1234/v1", "https://api.openai.com/v1"]) { + expect(wireMessages({ ...gateway, baseUrl })[2]).toEqual({ + role: "developer", + content: "Answer in exactly one sentence.", + }); + } + }); + + test("a destination that rejects the role converts it without moving the message", () => { + const messages = wireMessages({ ...gateway, foldDeveloperRoleToSystem: true }); + expect(messages.map(message => message.role)).toEqual(["system", "user", "system", "user"]); + expect(messages[2]).toEqual({ role: "system", content: "Answer in exactly one sentence." }); + expect(String(messages[0].content)).not.toContain("Answer in exactly one sentence."); + }); + + test("the opt-out is off unless the operator sets it", () => { + expect(wireMessages({ ...gateway, foldDeveloperRoleToSystem: false })[2].role).toBe("developer"); + }); +}); diff --git a/tests/adapters/openai/openai-chat-system-order.test.ts b/tests/adapters/openai/openai-chat-system-order.test.ts index def3179d814..29511492412 100644 --- a/tests/adapters/openai/openai-chat-system-order.test.ts +++ b/tests/adapters/openai/openai-chat-system-order.test.ts @@ -21,7 +21,7 @@ function buildMessages(context: OcxParsedRequest["context"]): Array { - test("folds interleaved developer reminders into one leading system message", () => { + test("keeps interleaved developer reminders in their original slots", () => { const messages = buildMessages({ systemPrompt: ["base instructions"], messages: [ @@ -44,13 +44,15 @@ describe("openai-chat system message ordering", () => { expect(messages[0]).toEqual({ role: "system", - content: "base instructions\n\nfirst reminder\n\nsecond reminder", + content: "base instructions", }); - expect(messages.slice(1).map(message => message.role)).toEqual(["user", "assistant", "user"]); - expect(messages.slice(1).some(message => message.role === "system")).toBe(false); + expect(messages.map(message => message.role)) + .toEqual(["system", "user", "developer", "assistant", "developer", "user"]); + expect(messages[2]).toEqual({ role: "developer", content: "first reminder" }); + expect(messages[4]).toEqual({ role: "developer", content: "second reminder" }); }); - test("keeps tool calls and results adjacent when a developer reminder follows the call", () => { + test("defers a reminder past a pending tool result instead of hoisting it", () => { const messages = buildMessages({ messages: [ { role: "user", content: "inspect", timestamp: 0 }, @@ -72,9 +74,11 @@ describe("openai-chat system message ordering", () => { ], }); - expect(messages[0]).toEqual({ role: "system", content: "remember the policy" }); - expect(messages.map(message => message.role)).toEqual(["system", "user", "assistant", "tool"]); - expect(messages[3]).toMatchObject({ role: "tool", tool_call_id: "call_1" }); + // The reminder arrived while call_1 was open. Emitting it there would break tool-call + // adjacency, so it is released immediately after the result rather than moved to the front. + expect(messages.map(message => message.role)).toEqual(["user", "assistant", "tool", "developer"]); + expect(messages[2]).toMatchObject({ role: "tool", tool_call_id: "call_1" }); + expect(messages[3]).toEqual({ role: "developer", content: "remember the policy" }); }); test("keeps developer vision content as a user-compatible message in place", () => { @@ -104,7 +108,7 @@ describe("openai-chat system message ordering", () => { }); }); -describe("OpenCode Go DeepSeek chronological system messages", () => { +describe("chronological in-conversation system messages", () => { const model = "deepseek-v4.1-flash"; const ocg: OcxProviderConfig = { adapter: "openai-chat", @@ -141,10 +145,10 @@ describe("OpenCode Go DeepSeek chronological system messages", () => { { role: "system", content: "Synthetic reminder B." }, ], ocg, model, stabilize); expect(JSON.stringify(next.messages.slice(0, first.messages.length))).toBe(JSON.stringify(first.messages)); - expect(first.messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant", "system"]); + expect(first.messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant", "developer"]); expect(first.messages[0].content).not.toContain("Synthetic reminder A."); - expect(first.messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); - expect(next.messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder B." }); + expect(first.messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); + expect(next.messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder B." }); expect(next.tools).toEqual(first.tools); expect(next.model).toBe(model); expect(next.stream).toBe(true); @@ -161,14 +165,14 @@ describe("OpenCode Go DeepSeek chronological system messages", () => { expect(callIndex).toBeGreaterThan(0); expect(body.messages[callIndex].reasoning_content).toBe(" "); expect(body.messages[callIndex + 1]).toMatchObject({ role: "tool", tool_call_id: "call_fixture", content: "Fixture result." }); - expect(body.messages[callIndex + 2]).toEqual({ role: "system", content: "Reminder during pending tool." }); + expect(body.messages[callIndex + 2]).toEqual({ role: "developer", content: "Reminder during pending tool." }); }); test.each([ "https://opencode.ai/zen/go/v1/", "https://opencode.ai:443/zen/go/v1", - ])("matches the canonical destination %s", baseUrl => { - expect(build(history, { ...ocg, baseUrl }).messages.at(-1).role).toBe("system"); + ])("keeps the reminder last on the canonical OpenCode Go destination %s", baseUrl => { + expect(build(history, { ...ocg, baseUrl }).messages.at(-1).role).toBe("developer"); }); test.each([ @@ -177,22 +181,34 @@ describe("OpenCode Go DeepSeek chronological system messages", () => { "https://opencode.ai:444/zen/go/v1", "http://opencode.ai/zen/go/v1", "http://localhost:1234/v1", - ])("retains generic hoisting for other destinations: %s", baseUrl => { + ])("keeps the same chronological placement on other destinations: %s", baseUrl => { const messages = build(history, { ...ocg, baseUrl }).messages; - expect(messages[0].content).toContain("Synthetic reminder A."); - expect(messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant"]); + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["system", "user", "assistant", "developer"]); + expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); - test("retains generic hoisting for other OCG models", () => { - expect(build(history, ocg, "kimi-k3").messages[0].content).toContain("Synthetic reminder A."); + test("placement no longer depends on the model either", () => { + const messages = build(history, ocg, "kimi-k3").messages; + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); - test("retains native OpenAI developer roles", () => { + test("the native OpenAI wire is unchanged", () => { const messages = build(history, { ...ocg, baseUrl: "https://api.openai.com/v1" }).messages; expect(messages[0].content).not.toContain("Synthetic reminder A."); expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); + test("a destination that rejects the role folds it in place", () => { + const messages = build(history, { ...ocg, foldDeveloperRoleToSystem: true }).messages; + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["system", "user", "assistant", "system"]); + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); + }); + test("drops a non-text timeline message instead of emitting an empty system message", () => { const context = { messages: [ diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 3db1a4a6275..5631ec1271a 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -4,6 +4,7 @@ import { AnthropicRequestError as LeafAnthropicRequestError } from "../../src/cl import { repoPath } from "../helpers/repo-root"; import { AnthropicRequestError, anthropicToResponsesBody, anthropicToResponsesTranslation, effortForThinkingBudget, extractOcxEffortDirective, resolveInboundModel } from "../../src/claude/inbound"; import { parseRequest } from "../../src/responses/parser"; +import { inlineDocumentMarker } from "../../src/responses/inline-document"; import { responsesRequestSchema } from "../../src/responses/schema"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; @@ -180,7 +181,7 @@ describe("claude inbound translation", () => { { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content }] }, ], }); - const marker = [{ type: "input_text", text: "[document: report.pdf]" }]; + const marker = [{ type: "input_text", text: inlineDocumentMarker("report.pdf") }]; expect(body.input).toEqual(carrier === "user" ? [{ type: "message", role: "user", content: marker }] : [ @@ -437,11 +438,11 @@ describe("claude inbound translation", () => { }) as any; expect(body.input[1].output).toEqual([ { type: "input_text", text: "3 pages" }, - { type: "input_text", text: "[document: report.pdf]" }, + { type: "input_text", text: inlineDocumentMarker("report.pdf") }, ]); // An untitled document still leaves a marker rather than the empty output that // read as "the tool returned nothing". - expect(body.input[3].output).toEqual([{ type: "input_text", text: "[document]" }]); + expect(body.input[3].output).toEqual([{ type: "input_text", text: inlineDocumentMarker(undefined) }]); expect(() => parseRequest(body)).not.toThrow(); }); diff --git a/tests/claude-integration/claude-source-envelope.test.ts b/tests/claude-integration/claude-source-envelope.test.ts index a78c9f1a152..a08b8f5e826 100644 --- a/tests/claude-integration/claude-source-envelope.test.ts +++ b/tests/claude-integration/claude-source-envelope.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { inlineDocumentMarker } from "../../src/responses/inline-document"; describe("Claude source envelope boundaries", () => { test("nested tool results retain only bounded structured content", () => { @@ -17,7 +18,7 @@ describe("Claude source envelope boundaries", () => { expect(body.input.map((item: any) => item.type)).toEqual(["function_call", "function_call_output"]); expect(body.input[1].output).toEqual([ { type: "input_text", text: "ok" }, - { type: "input_text", text: "[document: report]" }, + { type: "input_text", text: inlineDocumentMarker("report") }, ]); expect(JSON.stringify(body)).not.toContain("secret-payload"); }); diff --git a/tests/codex-integration/parallel-tool-calls-optin.test.ts b/tests/codex-integration/parallel-tool-calls-optin.test.ts index 689fd207f80..d62920f9e04 100644 --- a/tests/codex-integration/parallel-tool-calls-optin.test.ts +++ b/tests/codex-integration/parallel-tool-calls-optin.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { applyProviderConfigHints, normalizeRoutedCatalogEntry } from "../../src/codex/catalog"; import { routeModel } from "../../src/router"; -import type { OcxConfig, OcxParsedRequest, OcxTool } from "../../src/types"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; const tools: OcxTool[] = [{ name: "shell", description: "run", parameters: { type: "object" } }]; @@ -62,6 +62,73 @@ describe("parallel tool calls provider opt-in (request body)", () => { }); }); +/** + * #5211 case 2. The provider knob has three states and the call site only branched on two, so + * the default state — a provider that never configured it — dropped the caller's own explicit + * `parallel_tool_calls: false` on the way to the wire while still answering normally. The + * assertions read the request body because a successful tool call cannot tell the difference. + */ +describe("caller-specified parallel_tool_calls on a provider that expresses no preference", () => { + const unsetProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k" }; + + test("an explicit request-level false reaches the outbound request", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: false })).body) as Record; + expect(body.parallel_tool_calls).toBe(false); + }); + + test("an explicit request-level true still omits the knob strict hosts never had to accept", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: true })).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + test("a request that says nothing leaves the key absent", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest()).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + test("a toolless request never grows the key", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const toolless = { ...parsedRequest({ parallelToolCalls: false }), context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] } } as never; + const body = JSON.parse(adapter.buildRequest(toolless).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + // The native Chat route never projects the body, so it read the same three provider states + // from its own copy of the branch and lost the caller's false in exactly the same way. + describe("native Chat passthrough", () => { + function passthroughBody(provider: OcxProviderConfig, raw: Record): Record { + const request = buildOpenAIChatPassthroughRequest(provider, { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "shell", parameters: { type: "object" } } }], + ...raw, + }, "grok-4.5", false); + return JSON.parse(request.body) as Record; + } + + test("an explicit request-level false reaches the outbound request", () => { + expect(passthroughBody(unsetProvider, { parallel_tool_calls: false }).parallel_tool_calls).toBe(false); + }); + + test("an explicit true and an absent value both leave the key off", () => { + expect(passthroughBody(unsetProvider, { parallel_tool_calls: true })).not.toHaveProperty("parallel_tool_calls"); + expect(passthroughBody(unsetProvider, {})).not.toHaveProperty("parallel_tool_calls"); + }); + + test("the configured states keep their existing wire values", () => { + const optedIn = { ...unsetProvider, parallelToolCalls: true }; + expect(passthroughBody(optedIn, {}).parallel_tool_calls).toBe(true); + expect(passthroughBody(optedIn, { parallel_tool_calls: false }).parallel_tool_calls).toBe(false); + const optedOut = { ...unsetProvider, parallelToolCalls: false }; + expect(passthroughBody(optedOut, { parallel_tool_calls: true })).not.toHaveProperty("parallel_tool_calls"); + expect(passthroughBody({ ...optedOut, pinParallelToolCallsFalse: true }, { parallel_tool_calls: true }).parallel_tool_calls) + .toBe(false); + }); + }); +}); + describe("stale persisted config backfill (router)", () => { test("persisted xai config without the flag inherits registry parallelToolCalls:true", () => { const config: OcxConfig = { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 92dbb521190..5d1663715a3 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1400,6 +1400,11 @@ "web-search-sidecar-429.test.ts": "web-search", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "chat-tool-choice-allowed-tools.test.ts": "responses", + "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", + "google-strict-tool-validated-mode.test.ts": "adapters/google", + "openai-chat-developer-position.test.ts": "adapters/openai", + "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" } diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts new file mode 100644 index 00000000000..dd88fa05788 --- /dev/null +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../../src/chat/inbound"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { inlineDocumentDataUrl, inlineDocumentMarker } from "../../src/responses/inline-document"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; + +/** + * #5212. Both inbound parsers reduced an attachment to its name before any adapter ran, so no + * adapter could forward one even to a target that has a representation for it. The caller could + * not tell "the model read the document" from "the model was told a document existed", which is + * why every assertion here reads the outbound request rather than the response. + */ + +const PDF_BYTES = "JVBERi0xLjQK"; +// Derived, not restated: the wire spelling is the module's to define, and a test that repeats it +// fails for the wrong reason the next time it changes. +const PDF_DATA_URL = inlineDocumentDataUrl({ + type: "document", + text: "", + mediaType: "application/pdf", + data: PDF_BYTES, +}); + +const chatProvider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", +}; +const anthropicProvider = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "sk-x", + authMode: "apiKey", +} as unknown as OcxProviderConfig; + +function chatRequest(part: unknown): Record { + return { + model: "mock/test-model", + messages: [{ role: "user", content: [part] }], + }; +} + +function claudeRequest(block: unknown): Record { + return { + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: [block] }], + }; +} + +const DOCUMENT_BLOCK = { + type: "document", + title: "spec", + source: { type: "base64", media_type: "application/pdf", data: PDF_BYTES }, +}; + +/** The user turn's content, which `inputContentParts` collapses to a string for lone text. */ +function parsedContent(body: Record): unknown { + const parsed = parseRequest(body as never); + const user = parsed.context.messages.find(message => message.role === "user"); + return user!.content; +} + +describe("inline document bytes survive the inbound parse", () => { + test("a Chat file part becomes a document carrying its bytes", () => { + const content = parsedContent(chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { filename: "doc.pdf", file_data: PDF_DATA_URL }, + }))); + expect(content).toEqual([ + { + type: "document", + text: inlineDocumentMarker("doc.pdf"), + mediaType: "application/pdf", + data: PDF_BYTES, + filename: "doc.pdf", + }, + ]); + }); + + test("an Anthropic base64 document keeps its bytes and its title", () => { + const content = parsedContent(anthropicToResponsesBody(claudeRequest(DOCUMENT_BLOCK))); + expect(content).toEqual([ + { + type: "document", + text: inlineDocumentMarker("spec"), + mediaType: "application/pdf", + data: PDF_BYTES, + filename: "spec", + }, + ]); + }); + + test("a document with no usable bytes still reduces to the marker", () => { + const content = parsedContent(anthropicToResponsesBody(claudeRequest({ + type: "document", + title: "remote", + source: { type: "url", url: "https://example.com/doc.pdf" }, + }))); + expect(content).toBe(inlineDocumentMarker("remote")); + }); + + test("a reference with no payload is still refused rather than answered", () => { + expect(() => chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { file_id: "file-123" }, + }))).toThrow(ChatCompletionsRequestError); + expect(() => chatCompletionsToResponsesBody(chatRequest({ + type: "input_audio", + input_audio: { data: "AA==", format: "wav" }, + }))).toThrow(ChatCompletionsRequestError); + }); +}); + +describe("inline document bytes reach a wire that can hold them", () => { + function chatOutbound(body: Record): Record { + const parsed = parseRequest(body as never) as OcxParsedRequest; + return JSON.parse(createOpenAIChatAdapter(chatProvider).buildRequest(parsed).body) as Record; + } + + test("an Anthropic document reaches the OpenAI Chat wire as a file part", () => { + const outbound = chatOutbound(anthropicToResponsesBody(claudeRequest(DOCUMENT_BLOCK))); + const messages = outbound.messages as Array<{ role: string; content: unknown }>; + expect(messages.at(-1)).toEqual({ + role: "user", + content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }], + }); + }); + + test("a Chat file part reaches the Anthropic wire as a document block", async () => { + const parsed = parseRequest(chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { filename: "doc.pdf", file_data: PDF_DATA_URL }, + })) as never) as OcxParsedRequest; + const { body } = await createAnthropicAdapter(anthropicProvider).buildRequest(parsed); + const sent = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + messages: Array<{ role: string; content: unknown }>; + }; + expect(sent.messages.at(-1)).toEqual({ + role: "user", + content: [{ + type: "document", + source: { type: "base64", media_type: "application/pdf", data: PDF_BYTES }, + title: "doc.pdf", + }], + }); + }); + + test("a document beside text keeps both on the Chat wire", () => { + const outbound = chatOutbound(anthropicToResponsesBody({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: [{ type: "text", text: "Summarize it." }, DOCUMENT_BLOCK] }], + })); + const messages = outbound.messages as Array<{ role: string; content: unknown[] }>; + expect(messages.at(-1)!.content).toEqual([ + { type: "text", text: "Summarize it." }, + { type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }, + ]); + }); + + test("a developer turn carrying a document keeps its role", () => { + const parsed = { + modelId: "local-model", + context: { + messages: [{ + role: "developer", + content: [{ type: "document", text: inlineDocumentMarker("spec"), mediaType: "application/pdf", data: PDF_BYTES, filename: "spec" }], + timestamp: 0, + }], + }, + stream: false, + options: {}, + } as unknown as OcxParsedRequest; + const outbound = JSON.parse(createOpenAIChatAdapter(chatProvider).buildRequest(parsed).body) as { + messages: Array<{ role: string; content: unknown }>; + }; + expect(outbound.messages).toEqual([{ + role: "developer", + content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }], + }]); + }); +}); diff --git a/tests/responses/chat-media-translation.test.ts b/tests/responses/chat-media-translation.test.ts index a3042d4f3d7..d37c27ec871 100644 --- a/tests/responses/chat-media-translation.test.ts +++ b/tests/responses/chat-media-translation.test.ts @@ -16,9 +16,10 @@ const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https:// const media = [ { type: "input_audio", input_audio: { data: "YWJj", format: "wav" } }, { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }, - { type: "file", file: { filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } }, { type: "input_file", file_id: "file-private" }, ]; +// An inline document is carried in user content and still has no carrier anywhere else (#5212). +const INLINE_FILE = { type: "file", file: { filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } }; function chat(part: unknown, role = "user") { return { model: "model", messages: [{ role, tool_call_id: "call1", content: [{ type: "text", text: "read this" }, part] }] }; @@ -34,9 +35,24 @@ describe("Chat media stays native or fails explicitly at translation", () => { } }); + test("an inline document is carried in user content and refused where nothing carries it", () => { + const translated = chatCompletionsToResponsesBody(chat(INLINE_FILE)); + expect(translated.input).toEqual([{ + type: "message", + role: "user", + content: [ + { type: "input_text", text: "read this" }, + { type: "input_file", file_data: "data:application/pdf;base64,JVBERi0=", filename: "private.pdf" }, + ], + }]); + for (const role of ["tool", "system", "assistant"]) { + expect(() => chatCompletionsToResponsesBody(chat(INLINE_FILE, role))).toThrow("OpenCodex cannot translate"); + } + }); + test("the native Chat route retains the caller's exact media blocks", () => { const route = { provider, providerName: "gateway", modelId: "model" } as RouteResult; - for (const part of media) { + for (const part of [...media, INLINE_FILE]) { const raw = chat(part); expect(isNativeChatRouteEligible(route, raw)).toBe(true); const wire = JSON.parse(buildOpenAIChatPassthroughRequest(provider, raw, "model", false).body); @@ -59,8 +75,9 @@ describe("Chat media stays native or fails explicitly at translation", () => { }); test("plain text mentioning an attachment is not treated as one", () => { - const out = chatCompletionsToResponsesBody({ model: "model", messages: [{ role: "user", content: JSON.stringify(media) }] }); - expect(out.input).toEqual([{ type: "message", role: "user", content: [{ type: "input_text", text: JSON.stringify(media) }] }]); + const text = JSON.stringify([...media, INLINE_FILE]); + const out = chatCompletionsToResponsesBody({ model: "model", messages: [{ role: "user", content: text }] }); + expect(out.input).toEqual([{ type: "message", role: "user", content: [{ type: "input_text", text }] }]); }); }); @@ -93,7 +110,7 @@ test("real HTTP translation refuses media before sending to the selected upstrea server = startServer(0); for (const part of [ { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }, - { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }, + { type: "input_file", filename: "private.pdf", file_id: "file-private" }, ]) { const response = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/tests/responses/chat-tool-choice-allowed-tools.test.ts b/tests/responses/chat-tool-choice-allowed-tools.test.ts new file mode 100644 index 00000000000..181aaa325b0 --- /dev/null +++ b/tests/responses/chat-tool-choice-allowed-tools.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../../src/chat/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; + +/** + * #5211 case 1. A Chat Completions caller narrows the catalogue with + * `tool_choice.allowed_tools`, and the translated path used to let that object fall past every + * branch of `toolChoiceToResponses`: the request kept its full tool list, carried no tool choice + * at all, and still answered 200. Asserting on the translated body alone would not catch a + * later regression on the way out, so each case here follows the value into the request the + * adapter actually sends. + */ + +const CHAT_TOOLS = [ + { type: "function", function: { name: "tool_a", parameters: { type: "object", properties: {} } } }, + { type: "function", function: { name: "tool_b", parameters: { type: "object", properties: {} } } }, +]; + +function chatBody(toolChoice: unknown): Record { + return { + model: "mock/test-model", + messages: [{ role: "user", content: "Call only tool_b." }], + tools: CHAT_TOOLS, + tool_choice: toolChoice, + }; +} + +function outboundBody(toolChoice: unknown): Record { + const translated = chatCompletionsToResponsesBody(chatBody(toolChoice)); + const parsed = parseRequest(translated as never); + const adapter = createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", + }); + return JSON.parse(adapter.buildRequest(parsed as never).body) as Record; +} + +function outboundToolNames(body: Record): string[] { + return (body.tools as Array<{ function?: { name?: string } }>).map(tool => tool.function?.name ?? ""); +} + +describe("chat tool_choice allowed_tools reaches the outbound request", () => { + test("a single-tool subset narrows the tools the upstream is offered", () => { + const body = outboundBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", function: { name: "tool_b" } }] }, + }); + expect(outboundToolNames(body)).toEqual(["tool_b"]); + expect(body.tool_choice).toBe("required"); + }); + + test("a larger subset keeps every allowed tool and drops the rest", () => { + const body = outboundBody({ + type: "allowed_tools", + allowed_tools: { + mode: "auto", + tools: [ + { type: "function", function: { name: "tool_a" } }, + { type: "function", function: { name: "tool_b" } }, + ], + }, + }); + expect(outboundToolNames(body).sort()).toEqual(["tool_a", "tool_b"]); + expect(body.tool_choice).toBe("auto"); + }); + + test("the flat entry spelling and a flat choice object are both accepted", () => { + const nested = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", name: "tool_a" }] }, + })); + const flat = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "tool_a" }], + })); + expect(nested.tool_choice) + .toEqual({ type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "tool_a" }] }); + expect(flat.tool_choice).toEqual(nested.tool_choice); + }); + + test("mode defaults to auto and hosted entries are named by their type", () => { + const translated = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { tools: [{ type: "web_search" }] }, + })); + expect(translated.tool_choice) + .toEqual({ type: "allowed_tools", mode: "auto", tools: [{ type: "web_search" }] }); + }); + + test("an unnameable entry is refused rather than quietly widening the subset", () => { + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", function: {} }] }, + }))).toThrow(ChatCompletionsRequestError); + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [] }, + }))).toThrow(ChatCompletionsRequestError); + }); + + test("a selector kind nobody can evaluate is refused", () => { + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "mcp", name: "tool_a" }] }, + }))).toThrow(/unsupported tool_choice.allowed_tools.tools entry type/); + }); + + test("the existing named and string choices are unchanged", () => { + expect(chatCompletionsToResponsesBody(chatBody("required")).tool_choice).toBe("required"); + expect(chatCompletionsToResponsesBody(chatBody({ type: "function", function: { name: "tool_a" } })).tool_choice) + .toEqual({ type: "function", name: "tool_a" }); + }); +}); From 043aa435ff8f86095f55cbe08f74d45b9858da59 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 16:31:31 +0900 Subject: [PATCH 10/87] One stage, cause and resend vocabulary for a failed request (#5266) * refactor(usage): state the attempt recovery vocabulary once The recovery kinds were written twice: as a union and as the read-back whitelist that normalizedAttempt filters against. The two are not interchangeable. A member added only to the union compiles, is written to disk, and is then dropped on the next read, so the row loses the one field that says why the attempt recovered. Declare each vocabulary as a frozen roster and derive both the type and the Set from it, so the declaration cannot drift from itself. * feat(lib): one stage, cause and resend vocabulary for a failed request Roadmap items 7 and 14 want the same substrate: item 7 divides a failure into pre-header, headers-only, protocol prelude, semantic output, side effect and terminal and decides resend permission per stage; item 14 wants one cause dictionary spanning logical request, attempt, physical send and terminal. Defined separately they typecheck on each branch and contradict each other in the merge, which is the class that blocked 2.60.0, so they are one module. The resend decision is derived from three small per-member facts -- what the caller observed at a stage, what a cause proves about whether the origin ran the turn, and what a resend would have to change -- rather than written out as a stage-by-cause matrix. A matrix of that size is a restatement: it has to be re-derived by hand whenever a member is added, and the cell nobody revisited is how two correct branches merge into a wrong table. The module adds no record store. Durable shapes stay in src/usage/log.ts and projections read them structurally. It stays a leaf: both imports are types, erased at runtime, so nothing here reaches a request path that lacked it. The tests run over the full stage-by-cause cross product, so none of them can be satisfied by a request that returned 200 and none can go stale when a member is added. * feat(metrics): project recovery counters through the shared cause dictionary recoveryClass() ended in `default: return "other"`, so a recovery kind added later compiled cleanly and then disappeared into a bucket an operator cannot act on. Key the projection on the shared cause instead and make it total, so a missing member is a typecheck failure. This also separates four refusals that used to be indistinguishable in the counter. Waiting out a rate limit, changing account on quota exhaustion, changing the prompt on a policy refusal and dropping stale ciphertext are four different operator responses; `quota`, `policy` and `ciphertext` are new label values so the metric can tell them apart. An opaque blob rejection moves from `payload` to `ciphertext`, which is the one existing series whose meaning changes: the payload was never the problem, the stale encrypted state was. Label cardinality is unchanged in kind. Every value still comes from a frozen roster, so no user, model, account or request identifier can reach a series. * feat(responses): say the Codex WebSocket failure in the shared vocabulary The WebSocket transport was the one surface whose failures could not be compared with anything else, which is the reported symptom in #4191: an unanswered socket, a socket carrying only control frames and a socket that died mid-reply all reached the user as the same sentence. This is a projection, not a second classifier. classifyCodexWsFailure stays the only place that reads the counters; this restates its answer as the stage and cause the durable log, the metrics projection and the HTTP path already use. It does not relax the transport's own rule. The no-replay-after-send contract in codex-ws-exchange.ts holds regardless of what the projection returns; the shared table independently agrees that everything past before-send is refused. * fix(responses): recover from a relayed ciphertext rejection An OpenAI-compatible gateway does not forward the upstream error envelope; it puts the real payload inside its own message string. The single-shot sanitized rebuild keys on that envelope, so behind such a gateway it never matched and a turn carrying a stale reasoning blob failed outright instead of being resent without it. Recognise exactly one identity through the wrapper: an embedded invalid_request_error carrying invalid_encrypted_content. The generic classifier is deliberately NOT re-run against the embedded payload. Doing so would also admit the code-less unverifiable-ciphertext wording, the #4469 caller mismatch and the two xAI decoder strings, each of which was accepted on evidence about how one specific upstream words its own rejection -- and a gateway in between is not that evidence. The embedded object is found by counting braces outside string literals, because the payload legitimately contains braces and escaped quotes and the gateway appends prose after the closing brace. The scan is bounded so an upstream-controlled string cannot decide how much work the classifier does. Nothing else moves: the rebuild stays single-shot, still requires the send to have carried a blob, still requires a 4xx on the Responses adapter, and is still recorded as opaque-blob-rejection, which the shared table classifies as a ciphertext refusal repaired rather than repeated. The regression cases are mostly negative, because recognising the wrapper is the easy half and admitting only the coded identity through it is the half a broad implementation gets wrong. Co-authored-by: cmdy * docs: bind the resend rule and record the lane C dispositions INV-RESEND-01 states the rule the substrate exists to hold: once the caller has observed output or an externally visible effect no cause automatically permits a resend, and an unknown upstream execution state is not made replayable by having budget left. It is bound to the cross-product test, so deleting that file fails structure:check rather than quietly unbinding the rule. The management-api reference now lists the closed recovery label set, including that a rejected opaque reasoning blob counts as ciphertext rather than payload. The lane document records what was carried, what was deferred and why, including one defect found while mapping the substrate and deliberately not half-landed: the GUI declares its own recovery-kind roster with nine of the durable thirteen members, so four kinds render without a label. Fixing it needs strings across ten locale catalogs and a screenshot this branch cannot produce. --------- Co-authored-by: cmdy --- .../020_lane_c.md | 121 +++++++ .../content/docs/reference/management-api.md | 7 + scripts/test-layout/layout.json | 2 + src/lib/request-failure-model.ts | 308 ++++++++++++++++++ src/server/request-metrics.ts | 56 +++- src/server/responses/codex-ws-wire.ts | 32 ++ src/server/responses/core-opaque-recovery.ts | 90 +++++ src/usage/log.ts | 69 ++-- structure/gui-and-management-api.md | 5 +- structure/overview.md | 7 + tests/fixtures/test-layout-expected.json | 2 + tests/lib/failure-stage-model.test.ts | 205 ++++++++++++ .../opaque-blob-wrapped-rejection.test.ts | 171 ++++++++++ tests/responses/ws-failure-stage.test.ts | 37 +++ .../server/management-metrics-export.test.ts | 53 ++- 15 files changed, 1110 insertions(+), 55 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/020_lane_c.md create mode 100644 src/lib/request-failure-model.ts create mode 100644 tests/lib/failure-stage-model.test.ts create mode 100644 tests/responses/opaque-blob-wrapped-rejection.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_lane_c.md b/devlog/_plan/260920_meaning_preservation_batch/020_lane_c.md new file mode 100644 index 00000000000..a6c9b2edaa5 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_lane_c.md @@ -0,0 +1,121 @@ +# Lane C — retry stage table (7) and one event model (14) + +Status: OPEN. Branch `codex/260920-lane-c-retry-event-model`, cut from `dev` at +`b9483b3b510f9a8d99d465282517f7bf91678de3`. One branch, ordered commits, one pull request to +`dev`, per [010_phase2.md](010_phase2.md). + +## What this lane fixes first + +Bundles 7 and 14 want the same substrate, so the branch defines it before touching anything that +consumes it. `src/lib/request-failure-model.ts` is now the single statement of three things: + +- **Stage** — how far a failed exchange got, ordered by what the DOWNSTREAM CLIENT observed: + `pre-header`, `headers-only`, `protocol-prelude`, `semantic-output`, `side-effect`, `terminal`. + Ordering by client observation rather than by upstream progress is deliberate: the question the + table answers is whether a resend can duplicate something the caller already saw. +- **Cause** — one closed dictionary, with `rate-limit`, `quota-exhausted`, `policy-refusal` and + `ciphertext-refusal` as four separate members because their remedies are four different actions. + `parameter-rejected` is separate from `policy-refusal` for the same reason: the same content + succeeds once the parameter changes, and `payload-too-large` is separate from `payload-rejected` + because a smaller rebuild succeeds where no repair helps the other. +- **Resend permission** — derived from three small per-member facts, not written out as a + stage-by-cause matrix. A 6×14 matrix is a restatement that has to be re-derived by hand whenever + a member is added, and the cell nobody revisited is how two individually correct branches merge + into a wrong table. That is the class that blocked 2.60.0. + +A stage is how far the observable progression got, not which events happened to arrive. A turn that +settled carrying no output — an empty completion, a 4xx error body — did not reach `terminal`; it +stalled at `protocol-prelude`, because the caller saw no answer. `terminal` means the answer was +delivered, which is why it is both last and refused. Commitment is a named per-stage fact rather +than a rank comparison, so a stage added later cannot default into permission. + +`refused-ambiguous` forbids an AUTOMATIC resend. It does not forbid a narrowly scoped, explicitly +opted-in recovery that a maintainer reasoned about and bounded. That distinction is what separates +a sanctioned single-shot rebuild from a retry loop that fires because a counter had room, and it is +why this table can be honest about the recoveries the proxy already performs. + +Funding follows the disposition rather than the permission, for the same reason. The opt-in reset +replay, the bounded empty-completion rebuild and the transient 5xx ladder are all refused +automatically and all really send, so all three still name the allowance they draw on. Keying +funding on permission would leave exactly those paths unfunded, which is how a per-layer counter +comes back. + +Two classifications were corrected during review after being checked against what the code actually +does rather than against what the recovery kind is called. `transient-5xx` covers a status set that +mixes a 503 the origin declined with a 500 it may already have run, so it classifies as +`upstream-fault` and the table never claims the resend was provably safe. `console-go-upload-retry` +replays a byte-identical body that the gateway accepts seconds later, so nothing about the payload +was wrong and it classifies as `upstream-declined`. + +## No second store + +The durable shapes stay `PersistedUsageAttempt`, `PersistedRequestSpend` and +`PersistedUsageEntry` in `src/usage/log.ts`, joined by `addFinalRequestLog()`. That join is already +the one place a logical request id, its attempts, their physical `sendCount` and the terminal +outcome meet, so this lane derives from it rather than growing a parallel history. The new module +declares no record type and holds no state; both of its imports are types and are erased at +runtime, so it stays a leaf. + +## Restatements removed + +Two live instances of the union-defect class, both found while fixing the substrate: + +- `AttemptRecoveryKind` was written twice — as a union and as the read-back whitelist + `normalizedAttempt` filters against. A member added only to the union compiles, is written to + disk, and is dropped on the next read, so the row loses the field that says why it recovered. + Both vocabularies are now frozen rosters with the types derived from them. +- `recoveryClass()` in `src/server/request-metrics.ts` ended in `default: return "other"`, so a + recovery kind added later compiled cleanly and vanished into an unactionable bucket. It is now + total over the shared cause dictionary; a missing member is a typecheck failure. + +## Ownership + +This lane owns `sendCount`, the request-wide send budget, and the stage and cause vocabulary. +Lanes D and E consume them and do not redefine them. #4793 and every per-model cache view belong to +lane D; this branch edits neither and derives nothing from them. + +## Dispositions + +### Carried + +| Item | Disposition | +| --- | --- | +| #5245 (cmdy) | **Carried, narrowed.** Only an embedded `invalid_request_error` / `invalid_encrypted_content` is admitted through the gateway wrapper. The original reruns the whole opaque classifier on the embedded payload, which would also admit the code-less unverifiable-ciphertext wording, the #4469 caller mismatch and the two xAI decoder strings — identities accepted on evidence about how one specific upstream words its own rejection, which a gateway in between is not. A gateway envelope is now decided ONLY by its embedded payload: the pre-existing anchored-wording checks run on the whole message, and a gateway quotes the upstream's message inside its own, so a relayed caller mismatch would otherwise have satisfied the #4469 identity and gained a resend the strict check exists to withhold. Attribution is in the branch commit. | +| #4191 | **Addressed in part.** The WebSocket failure classifier now has a tested projection onto the shared stage and cause, so its four outcomes are stated in the same words as every other surface and the shared table independently reaches the transport's own no-replay-after-send verdict. The projection is not yet threaded into the durable record, and the SSE fallback the issue also asks for is a transport change; neither is in this branch. | +| #5180 | **Addressed in part.** `rate-limit` and `quota-exhausted` are separate causes with different resend decisions and different metric label values. The shared cooldown and `Retry-After` handling the issue also asks for are routing behaviour and are not in this branch. | + +### Deferred, with reasons + +| Item | Disposition | +| --- | --- | +| #4942 (FredAmartey) | **Deferred to a follow-up on this substrate.** The pre-header ambiguous-reset stage and its default refusal are now expressed in the shared table, which is what the PR's `replaySafe`/`replayResets` pair was duplicating. The PR itself is a 28-file transport change touching provider config, key failover and passthrough dispatch, and `dev` has moved under it around `request-execution-budget.ts` and `physical-send.ts`. Landing that reworked and unrun in a branch whose verification is static review would be a worse trade than deferring it. | +| #4989 | **Deferred to the same follow-up.** Its protocol-prelude state gate (`responseCreated && !outputCommitted && !terminal`) is exactly the `protocol-prelude` row of the shared table and is the correct model. It overlaps #4942 in `src/lib/upstream-retry.ts` and `passthrough-dispatch.ts`, and the two must not each buy an independent replacement send for one logical request, so they belong in one reworked change rather than two. | +| #2366 (chilung-cgu) | **Deferred.** Its `StreamTimeline`, `FailureSide` and seven-stage `FailureStage` are good source material and store nothing in parallel, but they are a second stage vocabulary. Reconciling them with the one landed here is a rewrite of the PR, not a carry, and it is better done once the substrate is on `dev`. | +| #3748 (yansigit) | **Deferred as implemented.** It adds an authoritative SQLite failure ledger beside the usage ledger, which is the parallel store this lane exists to avoid. The derived equivalent is to group recorder terminals by a versioned fingerprint of closed cause plus provider and model class. Its API also accepts a free-text `signature`, and regex redaction cannot prove content was removed. | +| #3983 (yansigit) | **Deferred.** Content-free and durable-store-free, but it emits through a second path independent of request recording. The derived form routes the same structural observations through the recorder and formats the debug ring from them. | +| #5063 (Vocllum) | **Deferred.** Sound retention work on the canonical ledger, and orthogonal to the stage and event model. It also changes GUI surface, which this branch cannot evidence. | +| GUI recovery-kind roster | **Deferred, and it is a real defect.** `gui/src/pages/Logs.tsx` declares its own `AttemptRecoveryKind` with nine of the durable thirteen members, so `key-401`, `oauth-account-429`, `opaque-blob-rejection` and `reasoning-effort-downgrade` have no localized label. Fixing it needs new strings across ten locale catalogs and a screenshot of the changed dialog, which a branch that may not build or run the GUI cannot produce. It should be one follow-up that derives the GUI union from the durable roster instead of restating it. | + +## Verification + +Static source review plus exact-head hosted CI, per the batch execution constraints. + +Checked statically on this branch: + +- every assertion in the two new test files was re-derived by hand from the declared tables, and + the only recovery kinds whose Prometheus class changes are `opaque-blob-rejection` + (`payload` to `ciphertext`) and `console-go-upload-retry` (`payload` to `transient`), both + corrections rather than side effects; +- every `satisfies Record` added here is total over its roster, and every value it + produces is a declared member of the target vocabulary; +- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` agree + key-for-key, and both new test files sit in the domain they are registered to; +- no file this branch touches has a `tests/fixtures/file-size-baseline.json` cap, and the new test + cases went into a sibling file rather than into `responses-opaque-blob-recovery.test.ts`, which + sits 148 lines under the new-file threshold; +- `src/server/index.ts` is untouched; it has one line of headroom against its cap. + +NOT RUN on this branch, by instruction: `bun run test`, any individual `bun test` file, +`bun run typecheck`, `bun run build:gui`, `bun install`, `bun run structure:check`, +`bun run privacy:scan`, and any live `ocx` execution. None of these may be recorded as passing. +Hosted CI at the exact head is the only execution evidence for this branch. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 4f47ad8bbc1..6dbf08fd9d8 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -290,6 +290,13 @@ boundary. Histogram buckets are cumulative and end with `le="+Inf"`, equal to th | `opencodex_ttft_missing_total` | `protocol`, `result` | Complementary count for requests without observed TTFT. | | `opencodex_metrics_process_start_time_seconds` | none | Process-local reset boundary. | +The `recovery` label takes one of a fixed set of classes: `transient`, `connection`, `credential`, +`rate_limit`, `quota`, `policy`, `ciphertext`, `payload`, `empty_completion`, `effort_downgrade` and +`other`. The set is closed, so no model, account, user or request identifier can ever appear in a +series. `rate_limit`, `quota`, `policy` and `ciphertext` are separate because the operator response +differs: wait out the limit, move to another account, change the prompt, or drop stale encrypted +state. A rejected opaque reasoning blob counts as `ciphertext` rather than `payload`. + If a scanned row exceeds the existing parser size limit, `GET /api/usage` and `GET /api/keys` keep the readable-row aggregates and add `usageIncomplete: true` with `usageIncompleteReason: "oversized_rows"` at response level. This diagnostic survives cached diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5c26e697f18..74b27f8859c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1063,6 +1063,7 @@ "openai-provider-option-tooling.test.ts": "adapters/openai", "openai-provider-option.test.ts": "adapters/openai", "openai-responses-passthrough.test.ts": "responses", + "opaque-blob-wrapped-rejection.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-management-transport.test.ts": "providers", "opencode-free-provider.test.ts": "providers", @@ -1168,6 +1169,7 @@ "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", + "failure-stage-model.test.ts": "lib", "relay-eager.test.ts": "server", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", diff --git a/src/lib/request-failure-model.ts b/src/lib/request-failure-model.ts new file mode 100644 index 00000000000..6066cdf616d --- /dev/null +++ b/src/lib/request-failure-model.ts @@ -0,0 +1,308 @@ +/** + * One vocabulary for how far a failed request got, why it failed, and whether this proxy may + * send it again (roadmap items 7 and 14). + * + * These two items are one module on purpose. Item 7 wants a resend decision per failure stage; + * item 14 wants one cause dictionary spanning logical request, attempt, physical send and + * terminal. Defined apart they typecheck on each branch and contradict each other in the merge, + * which is the class that blocked 2.60.0. + * + * What lives here is the vocabulary and the decision derived from it. What does NOT live here is + * a second record store: the durable shapes stay `PersistedUsageAttempt` and + * `PersistedUsageEntry` in src/usage/log.ts, and every projection below reads those structurally + * rather than growing a parallel history. + * + * MUST stay a leaf. Its only imports are types, erased at runtime, so nothing here can pull the + * usage or budget subsystems into a request path that did not already have them. + */ +import type { SendClass } from "./request-execution-budget"; +import type { AttemptRecoveryKind } from "../usage/log"; + +/** + * How far the exchange got, ordered by how much the DOWNSTREAM CLIENT observed. + * + * The order is by client observation rather than by upstream progress, because the question the + * table answers is whether resending can duplicate something the caller already saw. An upstream + * that completed a turn we never relayed has committed nothing downstream; an upstream that + * emitted one token has. + * + * A stage is how far the OBSERVABLE progression got, not which events happened to arrive. A turn + * that settled carrying no output -- an empty completion, a 4xx error body -- did not reach + * `terminal`; it stalled at `protocol-prelude`, because the caller saw no answer. `terminal` + * means the answer was delivered, which is why it is both last and refused. + */ +export const REQUEST_FAILURE_STAGES = Object.freeze([ + /** No response head exists. Whether the origin began the turn is not known from the stage alone. */ + "pre-header", + /** A status line and headers exist, and no protocol body event has been parsed yet. */ + "headers-only", + /** The protocol body began with control events only -- `response.created`, quota frames. */ + "protocol-prelude", + /** At least one output-bearing event reached the caller. */ + "semantic-output", + /** A tool call or other externally visible effect was emitted. */ + "side-effect", + /** A terminal event settled the turn after its answer reached the caller. */ + "terminal", +] as const); + +export type RequestFailureStage = typeof REQUEST_FAILURE_STAGES[number]; + +/** Position in {@link REQUEST_FAILURE_STAGES}. Derived, so the order is stated exactly once. */ +export function stageRank(stage: RequestFailureStage): number { + return REQUEST_FAILURE_STAGES.indexOf(stage); +} + +/** + * What the caller has irreversibly observed at a stage. + * + * Named separately from the rank so a reader can see WHY a stage refuses rather than inferring it + * from a position, and so the three committed stages stay distinguishable in a record. + */ +export type StageCommitment = "nothing-observed" | "output-observed" | "effect-observed" | "answer-delivered"; + +const STAGE_COMMITMENT = { + "pre-header": "nothing-observed", + "headers-only": "nothing-observed", + "protocol-prelude": "nothing-observed", + "semantic-output": "output-observed", + "side-effect": "effect-observed", + "terminal": "answer-delivered", +} as const satisfies Record; + +export function stageCommitment(stage: RequestFailureStage): StageCommitment { + return STAGE_COMMITMENT[stage]; +} + +/** + * Why the request failed, as one closed dictionary for every layer. + * + * Bounded on purpose: these are wire values a maintainer reads and a metric labels by, never a + * credential, an account identifier, an upstream body or prompt content. The four that #5180 and + * the ciphertext path insist on -- `rate-limit`, `quota-exhausted`, `policy-refusal` and + * `ciphertext-refusal` -- are separate members because they need opposite follow-ups: wait, + * change account, change the prompt, strip the ciphertext. + */ +export const REQUEST_FAILURE_CAUSES = Object.freeze([ + /** The bytes provably never reached the origin: connect refused, DNS failure, TLS handshake. */ + "transport-unsent", + /** The bytes left and the connection died before a head. The origin may be running the turn. */ + "transport-ambiguous", + /** The origin answered that it would not start the turn now: 503, overloaded, backpressure. */ + "upstream-declined", + /** A 429 rate limit. Capacity is momentarily gone; waiting is the remedy. */ + "rate-limit", + /** Plan or credit quota is gone. Waiting out a retry window does not help; the account must change. */ + "quota-exhausted", + /** Credentials were rejected: 401, 403 on identity. */ + "credential-rejected", + /** The origin evaluated the content and refused it. Identical bytes get the identical refusal. */ + "policy-refusal", + /** + * The origin rejected a request PARAMETER rather than the content: an unsupported reasoning + * effort, an unknown field. Distinct from `policy-refusal` because the remedy is opposite -- + * the same content succeeds once the parameter is adjusted. + */ + "parameter-rejected", + /** Opaque replay state was rejected as unverifiable. Only a request without it can succeed. */ + "ciphertext-refusal", + /** + * The payload exceeded a size the origin accepts. A smaller rebuild of the same turn can + * succeed, which is why this is not the same answer as `payload-rejected`. + */ + "payload-too-large", + /** The payload was rejected on its merits: unsupported media, malformed part. No repair helps. */ + "payload-rejected", + /** + * The origin returned a server-side fault. Whether it had already begun the turn is not + * knowable from the status, so this is the honest classification for the mixed 5xx set the + * transient layer retries: 503 really did decline, 500 may not have. + */ + "upstream-fault", + /** The turn settled carrying no usable output. */ + "empty-output", + /** The caller went away. */ + "client-cancelled", + /** This proxy refused before dispatch: send budget, route policy, replay refusal. */ + "local-refusal", +] as const); + +export type RequestFailureCause = typeof REQUEST_FAILURE_CAUSES[number]; + +/** + * What the cause proves about whether the origin ran the turn. + * + * This is the safety axis. `unknown` is the RFC 9110 9.2.2 case and is never upgraded by having + * budget left: a request whose upstream execution state is unknown is not replayable merely + * because a counter allows another send. + */ +export type UpstreamProcessingEvidence = "not-processed" | "declined" | "processed" | "unknown"; + +const CAUSE_EVIDENCE = { + "transport-unsent": "not-processed", + "transport-ambiguous": "unknown", + "upstream-declined": "declined", + "rate-limit": "declined", + "quota-exhausted": "declined", + "credential-rejected": "declined", + "policy-refusal": "processed", + "parameter-rejected": "declined", + "ciphertext-refusal": "declined", + "payload-too-large": "declined", + "payload-rejected": "declined", + "upstream-fault": "unknown", + "empty-output": "processed", + "client-cancelled": "unknown", + "local-refusal": "not-processed", +} as const satisfies Record; + +export function causeEvidence(cause: RequestFailureCause): UpstreamProcessingEvidence { + return CAUSE_EVIDENCE[cause]; +} + +/** + * What a resend would have to change to have any chance. + * + * The usefulness axis, orthogonal to safety. A policy refusal is perfectly safe to repeat and + * completely pointless; an ambiguous reset is the reverse. + */ +export type ResendDisposition = "resend-may-help" | "resend-after-repair" | "resend-is-futile"; + +const CAUSE_DISPOSITION = { + "transport-unsent": "resend-may-help", + "transport-ambiguous": "resend-may-help", + "upstream-declined": "resend-may-help", + "rate-limit": "resend-may-help", + "quota-exhausted": "resend-is-futile", + "credential-rejected": "resend-after-repair", + "policy-refusal": "resend-is-futile", + "parameter-rejected": "resend-after-repair", + "ciphertext-refusal": "resend-after-repair", + "payload-too-large": "resend-after-repair", + "payload-rejected": "resend-is-futile", + "upstream-fault": "resend-may-help", + "empty-output": "resend-may-help", + "client-cancelled": "resend-is-futile", + "local-refusal": "resend-is-futile", +} as const satisfies Record; + +export function causeDisposition(cause: RequestFailureCause): ResendDisposition { + return CAUSE_DISPOSITION[cause]; +} + +/** + * The answer this table exists to give. + * + * Every refusal names WHY it refused, because the three reasons need different operator + * responses and used to arrive as one undifferentiated "no retry". + */ +export type ResendPermission = + /** The same request may be sent again. */ + | "permitted" + /** Only a modified request may be sent: rotated credential, stripped ciphertext. */ + | "permitted-after-repair" + /** Upstream execution state is unknown. No AUTOMATIC resend; see the note below. */ + | "refused-ambiguous" + /** The caller already observed output or an externally visible effect. */ + | "refused-committed" + /** Identical bytes would get the identical answer. */ + | "refused-futile"; + +/** + * Whether this proxy may send the request again, from the stage it failed at and the cause. + * + * Derived from the two per-cause facts above and the per-stage commitment, rather than written + * out as a stage-by-cause matrix. A matrix of that size is a restatement: it would have to be + * re-derived by hand every time a member is added, and the cell nobody revisited is exactly how + * two correct branches merge into a wrong table. + * + * `refused-ambiguous` forbids an AUTOMATIC resend. It does not forbid a narrowly scoped, + * explicitly opted-in recovery that a maintainer reasoned about and bounded -- the reset replay + * behind a default-off provider flag, the single-shot empty-completion rebuild. Those are + * separate recorded decisions with their own acceptance, which is precisely what distinguishes + * them from a retry loop that fires because a counter had room. + */ +export function resendPermission( + stage: RequestFailureStage, + cause: RequestFailureCause, +): ResendPermission { + // Any stage at which the caller observed something refuses, whatever the cause says. Testing + // the commitment rather than listing the committed stages is what keeps a stage added later + // from defaulting into permission. + if (STAGE_COMMITMENT[stage] !== "nothing-observed") return "refused-committed"; + if (CAUSE_DISPOSITION[cause] === "resend-is-futile") return "refused-futile"; + const evidence = CAUSE_EVIDENCE[cause]; + if (evidence === "unknown" || evidence === "processed") return "refused-ambiguous"; + return CAUSE_DISPOSITION[cause] === "resend-after-repair" ? "permitted-after-repair" : "permitted"; +} + +/** True for the two permissions that allow a further send. */ +export function permitsResend(permission: ResendPermission): boolean { + return permission === "permitted" || permission === "permitted-after-repair"; +} + +/** + * Which request-wide send budget class a resend for this cause draws on, or null when no resend + * of any kind makes sense. + * + * Funding is keyed on the DISPOSITION, not on the permission. A cause the table refuses to resend + * automatically may still be resent by a narrowly scoped recovery a maintainer opted into, and + * that send has to be bought from the same budget every other send comes from -- the opt-in reset + * replay and the bounded empty-completion rebuild both draw on the transient allowance. Keying on + * permission instead would leave exactly those paths unfunded, which is how a per-layer counter + * reappears. + * + * Only a futile cause is null. `quota-exhausted` is null rather than `account-failover` because + * moving accounts is a route decision this table does not make. + */ +const CAUSE_SEND_CLASS = { + "transport-unsent": "transient", + "transport-ambiguous": "transient", + "upstream-declined": "transient", + "rate-limit": "transient", + "quota-exhausted": null, + "credential-rejected": "auth-recovery", + "policy-refusal": null, + "parameter-rejected": "repair", + "ciphertext-refusal": "repair", + "payload-too-large": "repair", + "payload-rejected": null, + "upstream-fault": "transient", + "empty-output": "transient", + "client-cancelled": null, + "local-refusal": null, +} as const satisfies Record; + +export function resendSendClass(cause: RequestFailureCause): SendClass | null { + return CAUSE_SEND_CLASS[cause]; +} + +/** + * The cause behind each recovery this proxy already records. + * + * Total over `AttemptRecoveryKind` by construction, so a new recovery kind is a typecheck + * failure here rather than a row that quietly classifies as "other" in three projections. + */ +const RECOVERY_KIND_CAUSE = { + // The retried status set mixes 503, which declined, with 500, which may already have run the + // turn. One kind cannot say both, so it says the weaker thing. + "transient-5xx": "upstream-fault", + "connection-reset": "transport-ambiguous", + "oauth-401": "credential-rejected", + "key-401": "credential-rejected", + "key-429": "rate-limit", + "rate-limit-429": "rate-limit", + "anthropic-oauth-429": "rate-limit", + "oauth-account-429": "rate-limit", + "image-413": "payload-too-large", + // The gateway rejects a body it accepts seconds later and the replay is byte-identical, so + // nothing about the payload was wrong; the origin declined to take it at that moment. + "console-go-upload-retry": "upstream-declined", + "opaque-blob-rejection": "ciphertext-refusal", + "empty-completion": "empty-output", + "reasoning-effort-downgrade": "parameter-rejected", +} as const satisfies Record; + +export function causeForRecoveryKind(kind: AttemptRecoveryKind): RequestFailureCause { + return RECOVERY_KIND_CAUSE[kind]; +} diff --git a/src/server/request-metrics.ts b/src/server/request-metrics.ts index d6126d5d9da..7ba966f5c45 100644 --- a/src/server/request-metrics.ts +++ b/src/server/request-metrics.ts @@ -1,13 +1,26 @@ import type { ResponsesTerminalStatus } from "../bridge"; import type { AttemptRecoveryKind } from "../usage/log"; +import { type RequestFailureCause, causeForRecoveryKind } from "../lib/request-failure-model"; export const REQUEST_METRICS_PROTOCOLS = Object.freeze(["responses", "chat", "messages", "unknown"] as const); export const REQUEST_METRICS_RESULTS = Object.freeze(["completed", "failed", "incomplete", "aborted"] as const); +/** + * Closed recovery classes exported as Prometheus label values. + * + * Bounded by construction: the label can only ever take one of these strings, so no user, model, + * account or request identifier can reach a series name. `quota`, `policy` and `ciphertext` are + * separate members because an operator seeing a spike needs to know which one it is -- waiting + * out a rate limit, changing accounts, changing the prompt and dropping stale ciphertext are + * four different responses, and collapsing them is what made the existing counter unactionable. + */ export const REQUEST_METRICS_RECOVERY_CLASSES = Object.freeze([ "transient", "connection", "credential", "rate_limit", + "quota", + "policy", + "ciphertext", "payload", "empty_completion", "effort_downgrade", @@ -83,23 +96,34 @@ function classifyResult(fact: RequestMetricFinalFact): RequestMetricsResult { return "failed"; } +/** + * Metrics class for each shared failure cause. + * + * Keyed on the cause rather than on the recovery kind so this projection and the durable log + * speak one vocabulary. Total by construction: the previous switch ended in `default: "other"`, + * which meant a recovery kind added later compiled cleanly and then disappeared into an + * unactionable bucket. A missing member is now a typecheck failure. + */ +const CAUSE_METRICS_CLASS = { + "transport-unsent": "connection", + "transport-ambiguous": "connection", + "upstream-declined": "transient", + "rate-limit": "rate_limit", + "quota-exhausted": "quota", + "credential-rejected": "credential", + "policy-refusal": "policy", + "parameter-rejected": "effort_downgrade", + "ciphertext-refusal": "ciphertext", + "payload-too-large": "payload", + "payload-rejected": "payload", + "upstream-fault": "transient", + "empty-output": "empty_completion", + "client-cancelled": "other", + "local-refusal": "other", +} as const satisfies Record; + function recoveryClass(kind: AttemptRecoveryKind): RequestMetricsRecoveryClass { - switch (kind) { - case "transient-5xx": return "transient"; - case "connection-reset": return "connection"; - case "oauth-401": - case "key-401": return "credential"; - case "key-429": - case "rate-limit-429": - case "anthropic-oauth-429": - case "oauth-account-429": return "rate_limit"; - case "image-413": - case "console-go-upload-retry": - case "opaque-blob-rejection": return "payload"; - case "empty-completion": return "empty_completion"; - case "reasoning-effort-downgrade": return "effort_downgrade"; - default: return "other"; - } + return CAUSE_METRICS_CLASS[causeForRecoveryKind(kind)]; } function observeHistogram(cell: HistogramCell, bounds: readonly number[], value: number): void { diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 35764c83522..8bef362d0c0 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -4,6 +4,7 @@ import { UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE, UPSTREAM_NO_RESPONSE_CODE, } from "../../lib/upstream-retry"; +import type { RequestFailureCause, RequestFailureStage } from "../../lib/request-failure-model"; import { readFileSync } from "node:fs"; // If the 101 never arrives (network black hole), give SSE a chance well before // the caller's connect timeout (default 200s) would fire. @@ -210,6 +211,37 @@ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailu return "no-response-event"; } +/** + * The same four outcomes said in the shared stage-and-cause vocabulary (#4191). + * + * A projection, not a second classifier: {@link classifyCodexWsFailure} stays the one place that + * reads the counters, and this only restates its answer in the words the durable log, the metrics + * projection and the HTTP path already use. Without it the WebSocket transport is the one surface + * whose failures cannot be compared with anything else, which is the reported symptom -- every + * such failure reached the user as one of two bare sentences. + * + * It does not relax the transport's own rule. The no-replay-after-send contract in + * `codex-ws-exchange.ts` holds regardless of what this returns, and the stage below is + * deliberately not consulted as a fallback-eligibility signal; it reports where the exchange got + * to, and `resendPermission` happens to agree that everything past `before-send` is refused. + */ +export const CODEX_WS_FAILURE_PROJECTION = { + /** The create frame never left, so the origin provably never saw this turn. */ + "before-send": { stage: "pre-header", cause: "transport-unsent" }, + /** The frame left and the socket said nothing at all. The turn may be running upstream. */ + "no-upstream-frame": { stage: "pre-header", cause: "transport-ambiguous" }, + /** Control frames only: the peer is alive and answered, but no Responses event arrived. */ + "no-response-event": { stage: "protocol-prelude", cause: "transport-ambiguous" }, + /** Events already reached the caller, so a resend would duplicate output they have seen. */ + "after-response-started": { stage: "semantic-output", cause: "transport-ambiguous" }, +} as const satisfies Record; + +export function projectCodexWsFailure( + stage: CodexWsFailureStage, +): { stage: RequestFailureStage; cause: RequestFailureCause } { + return CODEX_WS_FAILURE_PROJECTION[classifyCodexWsFailure(stage)]; +} + /** * Render the stage as a suffix appended to an existing failure message. * diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 03df9766863..13a65b76ea0 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -116,6 +116,88 @@ export function isReasoningBlobCallerMismatchMessage(message: string): boolean { } +/** + * Longest embedded payload this will parse. The wrapper is a short error envelope; anything + * larger is not the shape being matched, and refusing to walk it keeps an upstream-controlled + * string from deciding how much work the classifier does. + */ +const LITELLM_EMBEDDED_PAYLOAD_LIMIT = 16_384; +const LITELLM_WRAPPER_PREFIX = "litellm.BadRequestError:"; +const LITELLM_WRAPPER_MARKER = "OpenAIException - "; + +/** + * The JSON an OpenAI-compatible gateway embeds in its own error message, or undefined. + * + * Brace-aware rather than a regex because the embedded object legitimately contains braces and + * escaped quotes inside its message, and the gateway appends its own prose after the closing + * brace. Counting depth outside string literals is the only way to find the real end. + */ +function liteLlmEmbeddedErrorPayload(message: string): unknown { + if (!message.startsWith(LITELLM_WRAPPER_PREFIX)) return undefined; + const markerIndex = message.indexOf(LITELLM_WRAPPER_MARKER); + if (markerIndex < 0) return undefined; + const start = message.indexOf("{", markerIndex + LITELLM_WRAPPER_MARKER.length); + if (start < 0) return undefined; + const end = Math.min(message.length, start + LITELLM_EMBEDDED_PAYLOAD_LIMIT); + + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < end; index += 1) { + const character = message[index]!; + if (inString) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { inString = true; continue; } + if (character === "{") depth += 1; + else if (character === "}") { + depth -= 1; + if (depth === 0) { + try { return JSON.parse(message.slice(start, index + 1)) as unknown; } catch { return undefined; } + } + } + } + return undefined; +} + + +/** True for an error message that is a gateway envelope rather than an upstream's own wording. */ +function isLiteLlmEnvelopeMessage(message: string): boolean { + return message.startsWith(LITELLM_WRAPPER_PREFIX) && message.includes(LITELLM_WRAPPER_MARKER); +} + + +/** + * An OpenAI-compatible gateway relaying the one authoritative ciphertext rejection inside its + * own error string. + * + * Deliberately narrower than {@link isSelfIdentifiedOpaqueBlobRejection}. The embedded payload is + * matched against exactly one identity -- `invalid_request_error` carrying + * `invalid_encrypted_content` -- and the generic classifier is NOT re-run against it. Re-running + * it would let every other opaque identity arrive through the wrapper as well: the code-less + * unverifiable-ciphertext wording, the #4469 caller mismatch, and the two xAI decoder strings. + * Each of those was admitted on evidence from a specific upstream about how that upstream words + * its own rejection, and a gateway in between is not that evidence. Only the coded identity is + * unambiguous enough to survive relaying. + */ +export function isLiteLlmWrappedCiphertextRejection(payload: unknown): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const outer = (payload as { error?: unknown }).error; + if (!outer || typeof outer !== "object" || Array.isArray(outer)) return false; + const message = (outer as { message?: unknown }).message; + if (typeof message !== "string") return false; + const embedded = liteLlmEmbeddedErrorPayload(message); + if (!embedded || typeof embedded !== "object" || Array.isArray(embedded)) return false; + const inner = (embedded as { error?: unknown }).error; + if (!inner || typeof inner !== "object" || Array.isArray(inner)) return false; + const { type, code } = inner as { type?: unknown; code?: unknown }; + return type === "invalid_request_error" && code === "invalid_encrypted_content"; +} + + export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { if (isEncryptedFunctionOutputRejection(bodyText)) return true; try { @@ -132,6 +214,14 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) { const error = record.error as { type?: unknown; code?: unknown; message?: unknown }; + // A gateway envelope is decided ONLY by its embedded payload, before any wording check + // below runs. Those checks match anchored phrases anywhere in the message, and a gateway + // quotes the upstream's message inside its own -- so without this the relayed text would + // satisfy the caller-mismatch identity and gain a resend the strict wrapper check exists to + // withhold. Returning here rather than falling through is the point. + if (typeof error.message === "string" && isLiteLlmEnvelopeMessage(error.message)) { + return isLiteLlmWrappedCiphertextRejection(payload); + } if (error.type === "invalid_request_error") { if (error.code === "invalid_encrypted_content") return true; if ( diff --git a/src/usage/log.ts b/src/usage/log.ts index 4c1cc58204b..b1bd2193e02 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -61,21 +61,30 @@ export function isCodexPoolAccountLogLabel(value: unknown): value is "main" | `p /** * Recovery kinds recorded per attempt in the usage log; the GUI renders localized labels * for these wire values. + * + * The roster is the single statement of this vocabulary and the type is derived from it. It used + * to be written twice -- once as a union here, once as the read-back whitelist below -- and the + * two are not interchangeable: a member added only to the union is accepted by the compiler, + * written to disk, and then silently dropped by `normalizedAttempt`, so the row loses its reason + * on the next read. One declaration cannot drift from itself. */ -export type AttemptRecoveryKind = - | "transient-5xx" - | "connection-reset" - | "oauth-401" - | "key-401" - | "key-429" - | "rate-limit-429" - | "anthropic-oauth-429" - | "oauth-account-429" - | "image-413" - | "console-go-upload-retry" - | "opaque-blob-rejection" - | "empty-completion" - | "reasoning-effort-downgrade"; +export const ATTEMPT_RECOVERY_KIND_ROSTER = Object.freeze([ + "transient-5xx", + "connection-reset", + "oauth-401", + "key-401", + "key-429", + "rate-limit-429", + "anthropic-oauth-429", + "oauth-account-429", + "image-413", + "console-go-upload-retry", + "opaque-blob-rejection", + "empty-completion", + "reasoning-effort-downgrade", +] as const); + +export type AttemptRecoveryKind = typeof ATTEMPT_RECOVERY_KIND_ROSTER[number]; /** * Why a recovery this request was otherwise willing to make did not happen. @@ -92,10 +101,15 @@ export type AttemptRecoveryKind = * * Bounded vocabulary on purpose: it is a wire value a maintainer reads, never a credential, an * account id, an upstream body, prompt content, or exception text. + * + * Declared as a roster for the same reason as {@link ATTEMPT_RECOVERY_KIND_ROSTER}. */ -export type AttemptRecoveryWithheld = - | "retry-send-budget" - | "rotation-send-budget"; +export const ATTEMPT_RECOVERY_WITHHELD_ROSTER = Object.freeze([ + "retry-send-budget", + "rotation-send-budget", +] as const); + +export type AttemptRecoveryWithheld = typeof ATTEMPT_RECOVERY_WITHHELD_ROSTER[number]; /** Request-time upstream credential class, never a credential or account identifier. */ export type UsageCredentialSource = "grok-oauth" | "xai-api-key"; @@ -497,25 +511,8 @@ function normalizeUsageValue(usage: OcxUsage | undefined): OcxUsage | undefined }; } -const ATTEMPT_RECOVERY_KINDS = new Set([ - "transient-5xx", - "connection-reset", - "oauth-401", - "key-401", - "key-429", - "rate-limit-429", - "anthropic-oauth-429", - "oauth-account-429", - "image-413", - "console-go-upload-retry", - "opaque-blob-rejection", - "empty-completion", - "reasoning-effort-downgrade", -]); -const ATTEMPT_RECOVERY_WITHHELD = new Set([ - "retry-send-budget", - "rotation-send-budget", -]); +const ATTEMPT_RECOVERY_KINDS: ReadonlySet = new Set(ATTEMPT_RECOVERY_KIND_ROSTER); +const ATTEMPT_RECOVERY_WITHHELD: ReadonlySet = new Set(ATTEMPT_RECOVERY_WITHHELD_ROSTER); const USAGE_STATUSES = new Set([ "reported", "unreported", diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5ba41a3575d..d179abaa328 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -620,7 +620,10 @@ log scan, or persistence. Restart creates a fresh owner, resets every counter/hi `opencodex_metrics_process_start_time_seconds`. The label vocabularies are closed: protocol is `responses`, `chat`, `messages`, or `unknown`; result -is `completed`, `failed`, `incomplete`, or `aborted`; recovery is one of eight coarse classes. A +is `completed`, `failed`, `incomplete`, or `aborted`; recovery is one of the coarse classes listed in +`REQUEST_METRICS_RECOVERY_CLASSES`, which is the roster the exporter itself iterates. The count is +deliberately not restated here: it was written as eight, a bounded label value was added, and the +documentation then contradicted the output it describes. A logical request increments once, physical sends sum the finalized attempt counts, and each distinct recovery kind already retained on an attempt contributes once to its coarse class. HTTP 200 never overrides a failed terminal event. Duration observes every valid finalized duration; TTFT observes diff --git a/structure/overview.md b/structure/overview.md index c0627e2dd7f..7178b292b70 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -130,6 +130,13 @@ still cover the rule, which is a judgement only review makes. there, reported as an unidentified holder otherwise. A configured `port: 0` still asks the OS for a port, and an explicit `--port` still waits for its pin instead of hopping. Enforced by `tests/cli/cli-dispatch.test.ts`. +- **INV-RESEND-01** — One vocabulary in `src/lib/request-failure-model.ts` states how far a failed + request got, why it failed, and whether it may be sent again. Once the caller has observed output + or an externally visible effect, no cause automatically permits a resend, and a cause whose + upstream execution state is unknown is not made replayable by having budget left. A refusal names + which of the three refusals it is. The decision is derived from per-stage and per-cause facts + rather than written out as a stage-by-cause matrix, so a new member cannot leave a stale cell. + Enforced by `tests/lib/failure-stage-model.test.ts`. CI enumerates that domain layout through `scripts/ci/run-bun-test-batches.sh`. Its default general scope and 12-file/120-second process shape leave the dedicated Linux storage-policy and api-usage diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5d1663715a3..646d7e75263 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -889,6 +889,7 @@ "openai-provider-option-tooling.test.ts": "adapters/openai", "openai-provider-option.test.ts": "adapters/openai", "openai-responses-passthrough.test.ts": "responses", + "opaque-blob-wrapped-rejection.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-management-transport.test.ts": "providers", "opencode-free-provider.test.ts": "providers", @@ -994,6 +995,7 @@ "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", + "failure-stage-model.test.ts": "lib", "relay-eager.test.ts": "server", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", diff --git a/tests/lib/failure-stage-model.test.ts b/tests/lib/failure-stage-model.test.ts new file mode 100644 index 00000000000..501c6b9228e --- /dev/null +++ b/tests/lib/failure-stage-model.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "bun:test"; +import { + REQUEST_FAILURE_CAUSES, + REQUEST_FAILURE_STAGES, + causeDisposition, + causeEvidence, + causeForRecoveryKind, + permitsResend, + resendPermission, + resendSendClass, + stageCommitment, + stageRank, + type RequestFailureCause, + type RequestFailureStage, +} from "../../src/lib/request-failure-model"; +import { ATTEMPT_RECOVERY_KIND_ROSTER } from "../../src/usage/log"; + +/** + * Roadmap items 7 and 14. None of these cases can be satisfied by a request that returned 200: + * every one of them asks what the proxy would do NEXT after a specific failure, which is the + * question a success assertion cannot reach. + * + * They are written over the full cross product rather than over chosen examples on purpose. A + * table this shape is exactly where a merge of two individually correct branches goes wrong -- one + * adds a cause, the other adds a stage, and the cell neither author looked at is the defect. A + * loop over the declared rosters has no cell to miss and cannot go stale when a member is added. + * + * Holds INV-RESEND-01 from structure/overview.md. + */ + +const STAGES: readonly RequestFailureStage[] = REQUEST_FAILURE_STAGES; +const CAUSES: readonly RequestFailureCause[] = REQUEST_FAILURE_CAUSES; + +describe("request failure stage ordering", () => { + test("ranks are the declared positions, unique and gapless", () => { + expect(STAGES.map(stageRank)).toEqual(STAGES.map((_, index) => index)); + expect(new Set(STAGES).size).toBe(STAGES.length); + }); + + test("exactly one stage observes output and exactly one observes an effect", () => { + const byCommitment = STAGES.filter(stage => stageCommitment(stage) === "output-observed"); + const effects = STAGES.filter(stage => stageCommitment(stage) === "effect-observed"); + expect(byCommitment).toEqual(["semantic-output"]); + expect(effects).toEqual(["side-effect"]); + }); +}); + +describe("resend permission", () => { + test("an uncertain resend after output or a side effect is never automatically permitted", () => { + const permitted: string[] = []; + for (const stage of STAGES) { + const commitment = stageCommitment(stage); + if (commitment !== "output-observed" && commitment !== "effect-observed") continue; + for (const cause of CAUSES) { + const permission = resendPermission(stage, cause); + if (permission !== "refused-committed") permitted.push(`${stage}/${cause}=${permission}`); + } + } + expect(permitted).toEqual([]); + }); + + /** + * Stated as "neither output nor an effect" rather than "nothing at all", because a turn can + * settle without ever producing output. A terminal that failed on a rate limit committed nothing + * downstream and is safe to send again; forbidding it would be the same mistake as ranking + * `terminal` above `semantic-output` and calling that commitment. + */ + test("no permitted resend follows observed output or an observed effect", () => { + const leaks: string[] = []; + for (const stage of STAGES) { + const commitment = stageCommitment(stage); + if (commitment !== "output-observed" && commitment !== "effect-observed") continue; + for (const cause of CAUSES) { + if (permitsResend(resendPermission(stage, cause))) leaks.push(`${stage}/${cause}`); + } + } + expect(leaks).toEqual([]); + }); + + /** + * The #4989 boundary. A stream that announced itself and produced nothing may be replaced; the + * first output-bearing event closes that door for every cause at once. + */ + test("the prelude may still be replaced and the two observed stages may not", () => { + expect(resendPermission("protocol-prelude", "upstream-declined")).toBe("permitted"); + for (const stage of ["semantic-output", "side-effect"] as const) { + for (const cause of CAUSES) expect(permitsResend(resendPermission(stage, cause))).toBe(false); + } + }); + + test("an ambiguous transport failure is refused at every stage, budget or not", () => { + for (const stage of STAGES) { + expect(permitsResend(resendPermission(stage, "transport-ambiguous"))).toBe(false); + } + expect(resendPermission("pre-header", "transport-ambiguous")).toBe("refused-ambiguous"); + expect(resendPermission("semantic-output", "transport-ambiguous")).toBe("refused-committed"); + }); + + /** + * Stated as a property rather than as a list of causes, because a list here would be one more + * hand-maintained restatement of the dictionary -- the thing these cases exist to prevent. + */ + test("nothing is repeated at pre-header unless the origin provably did not run it", () => { + expect(resendPermission("pre-header", "transport-unsent")).toBe("permitted"); + const unproven = CAUSES + .filter(cause => permitsResend(resendPermission("pre-header", cause))) + .filter(cause => causeEvidence(cause) !== "not-processed" && causeEvidence(cause) !== "declined"); + expect(unproven).toEqual([]); + }); + + test("a refusal always says which kind of refusal it is", () => { + const refusals = new Set(); + for (const stage of STAGES) { + for (const cause of CAUSES) { + const permission = resendPermission(stage, cause); + if (!permitsResend(permission)) refusals.add(permission); + } + } + expect([...refusals].sort()).toEqual(["refused-ambiguous", "refused-committed", "refused-futile"]); + }); +}); + +describe("the four refusals an operator has to tell apart", () => { + /** + * #5180 reported these arriving as one undifferentiated failure. Waiting, changing account, + * changing the prompt and dropping stale ciphertext are four different responses, so the + * decision each one produces has to differ somewhere a caller can read. + */ + test("rate limit, quota exhaustion, policy refusal and ciphertext refusal never collapse", () => { + const quartet = ["rate-limit", "quota-exhausted", "policy-refusal", "ciphertext-refusal"] as const; + const decisions = quartet.map(cause => JSON.stringify([ + causeEvidence(cause), + causeDisposition(cause), + resendSendClass(cause), + resendPermission("headers-only", cause), + ])); + expect(new Set(decisions).size).toBe(quartet.length); + }); + + test("each of the four keeps the decision its remedy implies", () => { + expect(resendPermission("headers-only", "rate-limit")).toBe("permitted"); + expect(resendPermission("headers-only", "quota-exhausted")).toBe("refused-futile"); + expect(resendPermission("headers-only", "policy-refusal")).toBe("refused-futile"); + expect(resendPermission("headers-only", "ciphertext-refusal")).toBe("permitted-after-repair"); + }); + + test("a rejected parameter is not a refused prompt", () => { + expect(causeDisposition("parameter-rejected")).toBe("resend-after-repair"); + expect(causeDisposition("policy-refusal")).toBe("resend-is-futile"); + }); +}); + +describe("send funding agrees with the permission table", () => { + /** + * Funding follows the disposition, not the permission. A cause the table refuses to resend + * automatically may still be resent by a bounded opt-in recovery, and that send must still be + * bought from the request-wide budget -- an unfunded path is how a per-layer counter returns. + */ + test("a budget class is named for every cause a resend could ever help", () => { + const disagreements: string[] = []; + for (const cause of CAUSES) { + const futile = causeDisposition(cause) === "resend-is-futile"; + if (futile !== (resendSendClass(cause) === null)) disagreements.push(cause); + } + expect(disagreements).toEqual([]); + }); + + test("the bounded opt-in recoveries the proxy already performs are funded", () => { + // #4942's reset replay, the empty-completion rebuild and the transient 5xx ladder are all + // refused automatically and all really send, so all three need an allowance to draw on. + for (const cause of ["transport-ambiguous", "empty-output", "upstream-fault"] as const) { + expect(permitsResend(resendPermission("pre-header", cause))).toBe(false); + expect(resendSendClass(cause)).toBe("transient"); + } + }); +}); + +describe("recovery kinds speak the shared dictionary", () => { + test("every recorded recovery kind resolves to a declared cause", () => { + const unmapped = ATTEMPT_RECOVERY_KIND_ROSTER.filter( + kind => !CAUSES.includes(causeForRecoveryKind(kind)), + ); + expect(unmapped).toEqual([]); + }); + + test("the kinds that drive different recoveries do not share one cause", () => { + expect(causeForRecoveryKind("opaque-blob-rejection")).toBe("ciphertext-refusal"); + expect(causeForRecoveryKind("image-413")).toBe("payload-too-large"); + expect(causeForRecoveryKind("connection-reset")).toBe("transport-ambiguous"); + expect(causeForRecoveryKind("transient-5xx")).toBe("upstream-fault"); + expect(causeForRecoveryKind("reasoning-effort-downgrade")).toBe("parameter-rejected"); + }); + + /** + * Every kind in the roster names a recovery this proxy actually performs, so none of them may + * classify as futile. The first draft put the 413 image rebuild and the gateway upload replay + * under a futile cause, which would have had the shared model assert that recoveries visible in + * the durable log were forbidden and unfunded. + */ + test("no recorded recovery classifies as a resend that cannot help", () => { + const futile = ATTEMPT_RECOVERY_KIND_ROSTER + .filter(kind => causeDisposition(causeForRecoveryKind(kind)) === "resend-is-futile"); + expect(futile).toEqual([]); + }); +}); diff --git a/tests/responses/opaque-blob-wrapped-rejection.test.ts b/tests/responses/opaque-blob-wrapped-rejection.test.ts new file mode 100644 index 00000000000..f8a1bced390 --- /dev/null +++ b/tests/responses/opaque-blob-wrapped-rejection.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; +import { shouldAttemptOpaqueBlobRecovery } from "../../src/server/responses/core"; +import { isLiteLlmWrappedCiphertextRejection } from "../../src/server/responses/core-opaque-recovery"; +import { causeForRecoveryKind, resendPermission } from "../../src/lib/request-failure-model"; + +/** + * #5245: an OpenAI-compatible gateway relays the ciphertext rejection inside its own error + * string, so the identity the single-shot sanitized rebuild keys on never matched and the turn + * failed outright instead of retrying without the stale blob. + * + * Held in a sibling file rather than appended to responses-opaque-blob-recovery.test.ts, which + * sits 148 lines under the size ratchet's new-file threshold. Two branches can each stay under a + * cap alone and sum over it together, and the remedy for that is a move, never a number. + * + * The negative cases carry the weight here. Recognising the wrapper is easy; recognising ONLY + * the coded identity through it is the part a broad implementation gets wrong, because rerunning + * the whole classifier on the embedded payload silently admits four other identities that were + * each accepted on evidence about how one specific upstream words its own rejection. + */ + +const BLOB = "provider-minted-opaque-state"; + +function outboundWithBlob(): string { + return JSON.stringify({ + model: "model-a", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "before" }] }, + { type: "reasoning", content: [], summary: [], encrypted_content: BLOB, status: "completed" }, + ], + }); +} + +/** An upstream error body as a gateway relays it: the real payload inside a prose message. */ +function wrapped(inner: unknown, trailing = ""): string { + return JSON.stringify({ + error: { + message: `litellm.BadRequestError: OpenAIException - ${JSON.stringify(inner)}${trailing}`, + type: "invalid_request_error", + code: "400", + }, + }); +} + +const CODED_CIPHERTEXT_REJECTION = { + error: { + message: "The encrypted content could not be verified.", + type: "invalid_request_error", + code: "invalid_encrypted_content", + }, +}; + +const base = { + status: 400, + adapterName: "openai-responses", + outboundBody: outboundWithBlob(), + errorBody: wrapped(CODED_CIPHERTEXT_REJECTION), + alreadyAttempted: false, +}; + +describe("a relayed ciphertext rejection", () => { + test("earns the sanitized rebuild the direct rejection already earned", () => { + expect(shouldAttemptOpaqueBlobRecovery(base)).toBe(true); + }); + + test("survives braces and escaped quotes inside the relayed message", () => { + const awkward = { + error: { + message: 'The encrypted content {"id": "a\\"b"} could not be verified.', + type: "invalid_request_error", + code: "invalid_encrypted_content", + }, + }; + expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody: wrapped(awkward) })).toBe(true); + }); + + test("survives the prose a gateway appends after the payload", () => { + const errorBody = wrapped(CODED_CIPHERTEXT_REJECTION, " Received Model Group=gpt-5"); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody })).toBe(true); + }); + + test("is still single-shot and still requires the send to have carried a blob", () => { + expect(shouldAttemptOpaqueBlobRecovery({ ...base, alreadyAttempted: true })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + outboundBody: JSON.stringify({ model: "model-a", input: [{ type: "message", role: "user" }] }), + })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, status: 500 })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, adapterName: "openai-chat" })).toBe(false); + }); +}); + +describe("the wrapper admits the coded identity and nothing else", () => { + /** + * Each of these is accepted when the upstream states it DIRECTLY. None may be accepted through + * a relay: the wording evidence belongs to the upstream that produced it, and a gateway in + * between is not that evidence. + */ + const relayedButNotAdmitted: ReadonlyArray = [ + ["an unrelated parameter complaint", { + error: { type: "invalid_request_error", code: "unknown_parameter", message: "Unknown parameter" }, + }], + ["the code-less unverifiable-ciphertext wording", { + error: { + type: "invalid_request_error", + code: null, + message: "The encrypted content 6871-test-ef-0 could not be verified." + + " Reason: Encrypted content could not be decrypted or parsed.", + }, + }], + ["the caller-mismatch wording", { + error: { + type: "invalid_request_error", + code: null, + message: "reasoning `encrypted_content` was not issued to this caller", + }, + }], + ["an xAI compaction-blob decoder error", { + code: "invalid-argument", + error: "Could not decode the compaction blob: invalid payload", + }], + ["a rate limit", { error: { type: "rate_limit_error", code: "rate_limit_exceeded", message: "Slow down" } }], + ["a quota exhaustion", { error: { type: "insufficient_quota", code: "insufficient_quota", message: "No credit" } }], + ["a policy refusal", { error: { type: "invalid_request_error", code: "content_policy_violation", message: "Refused" } }], + ]; + + for (const [label, inner] of relayedButNotAdmitted) { + test(`does not resend on ${label}`, () => { + expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody: wrapped(inner) })).toBe(false); + }); + } + + test("ignores a message that only looks like the wrapper", () => { + expect(isLiteLlmWrappedCiphertextRejection(JSON.parse(wrapped(CODED_CIPHERTEXT_REJECTION)))).toBe(true); + expect(isLiteLlmWrappedCiphertextRejection({ + error: { message: `OpenAIException - ${JSON.stringify(CODED_CIPHERTEXT_REJECTION)}` }, + })).toBe(false); + expect(isLiteLlmWrappedCiphertextRejection({ error: { message: "litellm.BadRequestError: no payload" } })).toBe(false); + expect(isLiteLlmWrappedCiphertextRejection({ error: { message: 17 } })).toBe(false); + expect(isLiteLlmWrappedCiphertextRejection(null)).toBe(false); + }); + + test("refuses a relayed payload too large to be this envelope", () => { + const padded = { + error: { + message: "x".repeat(20_000), + type: "invalid_request_error", + code: "invalid_encrypted_content", + }, + }; + expect(isLiteLlmWrappedCiphertextRejection(JSON.parse(wrapped(padded)))).toBe(false); + }); +}); + +describe("the relayed rejection lands on the shared cause", () => { + /** + * The recovery this path takes is recorded as `opaque-blob-rejection`, and the shared table has + * to agree that a ciphertext refusal is repaired rather than repeated or waited out. If these + * ever disagree, the durable log and the metrics projection describe a different decision from + * the one the code made. + */ + test("is a ciphertext refusal, repaired rather than repeated", () => { + expect(causeForRecoveryKind("opaque-blob-rejection")).toBe("ciphertext-refusal"); + expect(resendPermission("headers-only", "ciphertext-refusal")).toBe("permitted-after-repair"); + expect(resendPermission("headers-only", "rate-limit")).toBe("permitted"); + expect(resendPermission("headers-only", "quota-exhausted")).toBe("refused-futile"); + }); + + test("a ciphertext refusal after output reached the caller is not repaired either", () => { + expect(resendPermission("semantic-output", "ciphertext-refusal")).toBe("refused-committed"); + }); +}); diff --git a/tests/responses/ws-failure-stage.test.ts b/tests/responses/ws-failure-stage.test.ts index e84145b04dc..7985d979878 100644 --- a/tests/responses/ws-failure-stage.test.ts +++ b/tests/responses/ws-failure-stage.test.ts @@ -9,10 +9,12 @@ import { closedBeforeTerminalMessage, codexWsFailureDetail, markCodexWsStage, + projectCodexWsFailure, readCodexWsStage, type CodexWsFailureStage, type CodexWsStageRecord, } from "../../src/server/responses/codex-ws-wire"; +import { permitsResend, resendPermission } from "../../src/lib/request-failure-model"; import { codexWsUpstreamFetch, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, @@ -161,6 +163,41 @@ describe("codex WS failure classification", () => { .toBe("before-send"); }); + /** + * The same four outcomes said in the shared stage-and-cause vocabulary, so a WebSocket failure + * can be compared with an HTTP one instead of being the one surface with private words for it. + * These rows are asserted individually because a projection is a mapping, and a mapping whose + * rows are only checked for totality can be rewritten wholesale without any case objecting. + */ + test("projects each outcome onto the shared stage and cause", () => { + expect(projectCodexWsFailure(stage({ sent: false, elapsedMs: null }))) + .toEqual({ stage: "pre-header", cause: "transport-unsent" }); + expect(projectCodexWsFailure(stage())) + .toEqual({ stage: "pre-header", cause: "transport-ambiguous" }); + expect(projectCodexWsFailure(stage({ upstreamFrames: 3, controlFrames: 3 }))) + .toEqual({ stage: "protocol-prelude", cause: "transport-ambiguous" }); + expect(projectCodexWsFailure(stage({ upstreamFrames: 9, controlFrames: 2, relayedEvents: 7 }))) + .toEqual({ stage: "semantic-output", cause: "transport-ambiguous" }); + }); + + /** + * The shared table has to reach the same verdict the transport already enforces on its own, or + * one of the two is lying about this exchange. A create frame that never left is the only + * outcome the origin provably did not see. + */ + test("only an unsent create frame may be sent again", () => { + const resendable = ([ + stage({ sent: false, elapsedMs: null }), + stage(), + stage({ upstreamFrames: 3, controlFrames: 3 }), + stage({ upstreamFrames: 9, controlFrames: 2, relayedEvents: 7 }), + ]).map(candidate => { + const projected = projectCodexWsFailure(candidate); + return permitsResend(resendPermission(projected.stage, projected.cause)); + }); + expect(resendable).toEqual([true, false, false, false]); + }); + test("renders every field, with n/a for the durations that do not exist yet", () => { expect(codexWsFailureDetail(stage({ upstreamFrames: 2, controlFrames: 2, firstFrameMs: 41 }))).toBe( " [cause=no-response-event request=812B sent=yes frames=2 control=2 relayed=0" diff --git a/tests/server/management-metrics-export.test.ts b/tests/server/management-metrics-export.test.ts index cccc37dc546..9759a2d91b0 100644 --- a/tests/server/management-metrics-export.test.ts +++ b/tests/server/management-metrics-export.test.ts @@ -16,7 +16,14 @@ import { type RequestLogContext, type RequestLogEntry, } from "../../src/server/request-log"; -import { createRequestMetricsOwner } from "../../src/server/request-metrics"; +import { + createRequestMetricsOwner, + REQUEST_DURATION_BUCKETS_SECONDS, + REQUEST_METRICS_PROTOCOLS, + REQUEST_METRICS_RECOVERY_CLASSES, + REQUEST_METRICS_RESULTS, + REQUEST_TTFT_BUCKETS_SECONDS, +} from "../../src/server/request-metrics"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import type { AttemptRecoveryKind } from "../../src/usage/log"; @@ -339,6 +346,31 @@ describe("request metrics aggregation", () => { expect(sampleValue(output, 'opencodex_recoveries_total{protocol="responses",recovery="rate_limit"}')).toBe(2); }); + test("the four refusals an operator responds to differently get four different classes", () => { + const metrics = createRequestMetricsOwner(123); + addFinalRequestLog("refusal-classes", Date.now() - 1_000, { + model: "m", provider: "p", inboundProtocol: "responses", requestMetricsRecorder: metrics, + attempts: [ + attempt(1, ["opaque-blob-rejection"]), + { ...attempt(1, ["rate-limit-429"]), ordinal: 2 }, + { ...attempt(1, ["reasoning-effort-downgrade"]), ordinal: 3 }, + { ...attempt(1, ["image-413"]), ordinal: 4 }, + ], + } as RequestLogContext, 400, undefined, () => {}); + + const output = metrics.snapshot(); + // A rejected opaque blob is a ciphertext refusal, not a payload problem: the payload was fine + // and the stale encrypted state was not. Counting it as payload alongside an oversize image + // told an operator to look at the wrong thing. + expect(sampleValue(output, 'opencodex_recoveries_total{protocol="responses",recovery="ciphertext"}')).toBe(1); + expect(sampleValue(output, 'opencodex_recoveries_total{protocol="responses",recovery="payload"}')).toBe(1); + expect(sampleValue(output, 'opencodex_recoveries_total{protocol="responses",recovery="rate_limit"}')).toBe(1); + expect(sampleValue(output, 'opencodex_recoveries_total{protocol="responses",recovery="effort_downgrade"}')).toBe(1); + expect(sampleValue(output, 'opencodex_recoveries_total{protocol="responses",recovery="quota"}')).toBe(0); + expect(sampleValue(output, 'opencodex_recoveries_total{protocol="responses",recovery="policy"}')).toBe(0); + expect(sampleValue(output, 'opencodex_recoveries_total{protocol="responses",recovery="other"}')).toBe(0); + }); + test("a failed terminal carried over HTTP 200 is never counted as completed", () => { const metrics = createRequestMetricsOwner(123); metrics.recordFinalRequest({ @@ -405,6 +437,7 @@ describe("request metrics aggregation", () => { toolBody: canaries[8], } as unknown as RequestLogContext; addFinalRequestLog(canaries[0]!, Date.now() - 1, logCtx, 400, undefined, () => {}); + const beforeFanOut = metrics.snapshot().split("\n").filter(line => line && !line.startsWith("#")).length; for (let index = 0; index < 64; index += 1) { addFinalRequestLog(`request-${index}`, Date.now() - 1, { model: `model-${index}`, @@ -417,7 +450,23 @@ describe("request metrics aggregation", () => { const output = metrics.snapshot(); for (const canary of canaries) expect(output).not.toContain(canary); const samples = output.split("\n").filter(line => line && !line.startsWith("#")); - expect(samples).toHaveLength(453); + // The property, stated directly: 64 requests carrying 64 distinct models, providers, keys and + // account labels add no series at all. A dynamic label map would show up here as growth. + expect(samples).toHaveLength(beforeFanOut); + // And the absolute size, derived from the closed vocabularies rather than restated as a + // literal. The literal was correct and went stale the moment a bounded label value was added, + // which is the failure mode this repository keeps hitting in merges. + const perHistogram = (bounds: readonly number[]): number => bounds.length + 1 + 2; + const cells = REQUEST_METRICS_PROTOCOLS.length * REQUEST_METRICS_RESULTS.length; + expect(samples).toHaveLength( + cells + + REQUEST_METRICS_PROTOCOLS.length + + REQUEST_METRICS_PROTOCOLS.length * REQUEST_METRICS_RECOVERY_CLASSES.length + + cells * perHistogram(REQUEST_DURATION_BUCKETS_SECONDS) + + cells * perHistogram(REQUEST_TTFT_BUCKETS_SECONDS) + + cells + + 1, + ); }); test("text exposition has deterministic HELP/TYPE groups and cumulative +Inf buckets", () => { From 53725b0dec3e20f1d6e5d0e144aab4a3ab722806 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 17:19:46 +0900 Subject: [PATCH 11/87] fix(codex,usage): bind account-scoped evidence to credential generation and add privacy-bounded cache diagnostics (#5268) * fix(codex): preserve cache affinity across model detours Carries #5209. A gated-model detour under pool.cacheAffinity + the quota strategy evicted a cache-warm shared binding on a threshold crossing (a hint), before the account was actually exhausted. The three shared-state/affinity preservation predicates now use the 100%-exhaustion boundary via hasCodexSharedStateQuotaHeadroom, matching live-binding quota re-evaluation. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * fix(reasoning): scope learned reasoning-effort refusals to credential identity Carries #5145. A learned upstream refusal was persisted under a destination-wide key (provider, model, effort), so every credential reaching the same destination inherited it. Each learned fact is now bound to a one-way SHA-256 digest of the active credential; the support row key becomes a JSON array; the snapshot advances to version 2 and legacy destination-wide rows are ignored on load. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * fix(cursor): isolate live roster and Max Mode evidence by account Carries #5229. Cursor pooled accounts shared module-level singletons for the Claude wire-spelling map and the Max-Mode evidence set, so a discovery recorded under one credential could rewrite the wire id or arm ultra for a request resolved under a different account. Both maps are now keyed by a non-secret sha256 scope over the upstream destination and credential, and a provider-scoped evidence entry is dropped when its model cache clears. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * fix(codex): fence entitlement credential refreshes behind admission Carries #5214. Background and data-plane entitlement resolves (catalog sync, convergence, serve-options /models, CLI startup discovery, ensureCodexEntitlementFreshness) could refresh or rewrite the native auth.json while native-main lifecycle, recovery, or profile-switch drains intend the physical native identity to stay untouched, and a refused claim also took down Pool discovery. Adds model-entitlement-admission.ts plus withNativeMainCredentialAdmission in native-main-admission.ts, applied at the five sites; the test file lands in the codex-integration domain registered in the layout map. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * feat(usage): show cache metrics by model Carries #4793. The Usage page's Models table now shows input tokens, output tokens, cache hits, cache writes, and cache hit rate for each model; providers without cache telemetry render an em dash. Includes translations for all supported GUI locales, dashboard documentation, and a rendered GUI regression test. Co-authored-by: xdober <10195626+xdober@users.noreply.github.com> * test(codex): move cache-affinity detour cases to a sibling under the file-size cap codex-routing.test.ts sits exactly at its file-size cap; the carried #5209 cases would have grown it 83 lines over. The three detour cases move to codex-routing-cache-affinity-detour.test.ts byte for byte with their own minimal harness, registered in both layout.json and the expected fixture. * fix(codex): bind Cursor and Devin live rosters to the observing credential The live Cursor and Devin model rosters are entitlement-specific, but their provider roster cache was scoped by provider name alone: a credential switch could read the previous account's fresh or stale plan roster, and a failed discovery's cooldown suppressed the next credential's first fetch while offering it the previous account's stale list. Bind the cache entry to an irreversible credential fingerprint (the Qoder precedent), make the stale fallback credential-scoped, and let a credential with no roster of its own fetch through another credential's cooldown. Quota and rate-limit health stay account-scoped by design: they describe the subscription, not the token generation, and the 401/403 quarantine is already generation-fenced. * fix(codex): fence cancelled entitlement refreshes behind caller cancellation A data-plane /v1/models request now passes its own signal into admitted entitlement resolution, and the native-main token refresh re-checks that signal after the upstream grant resolves and before the auth.json commit: a refresh that resolves after its caller went away no longer rewrites the physical credential on behalf of a request that no longer exists. The reauth twin already fenced its commit the same way; the roster-cache publication stays fenced by credential identity and mutation epoch, which is the correct boundary for a shared flight. * feat(usage): opt-in privacy-bounded cache diagnostic (#5178) Under OPENCODEX_CACHE_DEBUG=1 the proxy writes one record per finalized request to /cache-debug.jsonl (0600, 200-to-100 rolling), letting an operator compare two requests and tell a client prefix change, an account change, and a proxy transformation change apart as the cause of a cache-read drop. Records hold only presence booleans, counts, closed enums, the raw upstream cache counter before defaulting, and process-local HMAC equality tags (independent process-random key, never persisted) for the prompt-cache key, allowlisted session headers, the account log label, and ordered instruction/tool/message blocks capped at 128 per section with only the first divergent section/index. No prompt text, tool names, raw identifiers, or header values are recorded, and no tag survives a process restart, so a fingerprint can never become a public or durable correlation key. The request path reaches the module through a process-local registration hook so responses/core.ts gains no runtime import, and an all-zero usage frame with a measured cache counter now survives extraction instead of collapsing to "unreported", which is what keeps a measured zero distinct from an absent counter downstream. Off by default. * docs(devlog): record lane D account/cache-generation progress * fix(usage,tests): close review findings on the diagnostic and the moved admission test Pre-CI adversarial review found two blocking defects: the carried entitlement-admission test kept its tests-root import paths after the domain move (every case failed at load), and the diagnostic's block splitter aliased an array-valued instructions field, so observation would have mutated the live request body the adapter was about to serialize. Both are fixed, the second with a mutation regression test. The all-zero usage extraction change is reverted: it reclassified spend settlement for placeholder frames, and the measured-zero versus absent distinction already rides the provenance enum for every frame that reports tokens. * fix(catalog,codex): derive the reasoning-rung type and scope discovery cooldown to its credential Exact-head CI on this branch failed gates, both typecheck-dependent shards and one Cursor case. Three causes, fixed here. catalog/effort.ts and catalog/build-entries.ts cast a partially populated ladder to Array<{ effort?: string }> and push a canonical CODEX_REASONING_LEVELS rung into it, which also carries description. That was always a type error, but reasoning-effort.ts -> providers/reasoning-metadata.ts -> providers/key-store.ts -> the ../config barrel formed an import cycle in which the rung type degraded and the excess-property check never ran. Carried #5145 breaks that cycle by design, so the latent error surfaced here first. reasoning-effort.ts now exports CodexReasoningLevel and the three casts derive Array> from it rather than restating a narrower shape. The translator-budget contract test, which spawns tsc over the project, was downstream of these errors. The Cursor cooldown case was a real regression from this lane. Scoping only the roster reads to the credential left the failure cooldown provider-wide, so the branch had to require a credential-scoped stale entry before honouring it, and a discovery that fails before caching anything has no stale entry -- reopening the timeout storm #54 closed. The scope now sits where the observation belongs: a discovery failure records the credential that observed it and suppresses only that credential. A failure recorded without an identity stays credential-agnostic and suppresses everyone, so plain-endpoint providers and the existing Qoder branch are unchanged. cache-diagnostic.ts narrowed draft.promptCacheKey through optional chaining and then read it again unguarded; the inbound key is bound once. * fix(gui-tests): derive the usage header and locale symbol checks from their sources The carried #4793 columns broke three GUI assertions that restate what the page and the catalogs already own. usage-custom-range listed the models-table headers as English literals and omitted the API list-price column that ships today, so the case failed on any tree where both exist. The expectation now maps the ordered column keys the page renders through the en catalog, which is where that copy lives. The French accidental-English guard and the zh-TW stale-placeholder guard both flagged usage.unavailable, whose value is an em dash. A value with no letters once its placeholders are removed has nothing to translate and is identical in every locale by construction, so both checks now derive that from the value instead of taking one more allowlist entry. Real words still fail: the existing entries that carry letters, such as uptime.hour, remain allowlisted and required. * docs(devlog): record the lane D CI dispositions * test(ci): quarantine the 50 MiB sideband relay case into its own lane sideband GET /v1/live/{callId} relays a 50 MiB WebSocket frame end to end against a hard 15s deadline while sharing a process with the rest of its --shard=N/2 half, so its result measures the whole process rather than the relay. On dev it lands in shard 1 and its echo leg alone spends 7.4s of that budget. Three test files added elsewhere in this branch made Bun repartition the halves, the case moved to shard 2, and the echo leg went past 15s twice with the peer never receiving the frame -- with nothing on the sideband path changed. SERIAL_FULL_SUITE_FILES is the mechanism this repository already has for that category; its own guard describes it as quarantining load-sensitive files into one-worker lanes. The deadline, the assertion and the macOS leg are unchanged; the case simply stops sharing a process, which also keeps it from breaking the next branch that adds a test file anywhere in the tree. --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> Co-authored-by: xdober <10195626+xdober@users.noreply.github.com> --- .github/pr-assets/opencodex-cache-usage.png | Bin 0 -> 232808 bytes .../050_lane_d_account_cache_generation.md | 226 +++++++++++++ .../content/docs/guides/codex-integration.md | 16 + .../src/content/docs/guides/web-dashboard.md | 2 +- gui/src/i18n/de.ts | 6 + gui/src/i18n/en.ts | 6 + gui/src/i18n/fr.ts | 6 + gui/src/i18n/ja.ts | 6 + gui/src/i18n/ko.ts | 6 + gui/src/i18n/ru.ts | 6 + gui/src/i18n/tr.ts | 6 + gui/src/i18n/vi.ts | 6 + gui/src/i18n/zh-TW.ts | 6 + gui/src/i18n/zh.ts | 6 + gui/src/pages/Usage.tsx | 34 ++ gui/tests/fr-localization.test.ts | 15 +- gui/tests/locale-parity.test.ts | 11 + gui/tests/usage-custom-range.test.tsx | 60 ++++ scripts/test-layout/layout.json | 4 + scripts/test.ts | 9 + src/adapters/cursor.ts | 13 +- src/adapters/cursor/catalog.ts | 58 +++- src/adapters/cursor/request-builder.ts | 13 +- src/adapters/openai-responses/passthrough.ts | 2 + src/cli/index.ts | 4 +- src/codex/catalog/build-entries.ts | 4 +- src/codex/catalog/effort.ts | 6 +- src/codex/catalog/provider-models.ts | 39 ++- src/codex/catalog/retained-sync.ts | 4 +- src/codex/convergence.ts | 4 +- src/codex/main-account.ts | 6 + src/codex/model-cache.ts | 44 ++- src/codex/model-entitlement-admission.ts | 59 ++++ src/codex/model-entitlements.ts | 131 +++++--- src/codex/native-main-admission.ts | 83 +++++ src/codex/routing.ts | 46 +-- src/codex/routing/health-store.ts | 39 +++ src/codex/routing/selection.ts | 38 ++- src/providers/api-key-resolve.ts | 133 ++++++++ src/providers/key-store.ts | 144 ++------ src/providers/reasoning-metadata.ts | 61 +++- src/reasoning-effort.ts | 8 + src/server/index/serve-options.ts | 10 +- src/server/request-log.ts | 53 +++ src/server/responses/request-prepare.ts | 14 +- src/usage/cache-diagnostic.ts | 305 +++++++++++++++++ structure/catalog.md | 4 + structure/gui-and-management-api.md | 10 + structure/providers/openai-tiers.md | 7 +- .../codex-main-account-refresh.test.ts | 24 ++ .../codex-model-entitlement-admission.test.ts | 307 ++++++++++++++++++ ...odex-routing-cache-affinity-detour.test.ts | 199 ++++++++++++ tests/codex-integration/codex-routing.test.ts | 4 +- .../reasoning-metadata.test.ts | 130 +++++++- tests/fixtures/test-layout-expected.json | 4 + tests/providers/cursor/cursor-catalog.test.ts | 26 ++ .../cursor-roster-account-scope.test.ts | 78 +++++ tests/providers/devin-live-models.test.ts | 19 ++ tests/usage/cache-diagnostic.test.ts | 209 ++++++++++++ 59 files changed, 2503 insertions(+), 276 deletions(-) create mode 100644 .github/pr-assets/opencodex-cache-usage.png create mode 100644 devlog/_plan/260920_meaning_preservation_batch/050_lane_d_account_cache_generation.md create mode 100644 src/codex/model-entitlement-admission.ts create mode 100644 src/providers/api-key-resolve.ts create mode 100644 src/usage/cache-diagnostic.ts create mode 100644 tests/codex-integration/codex-model-entitlement-admission.test.ts create mode 100644 tests/codex-integration/codex-routing-cache-affinity-detour.test.ts create mode 100644 tests/providers/cursor/cursor-roster-account-scope.test.ts create mode 100644 tests/usage/cache-diagnostic.test.ts diff --git a/.github/pr-assets/opencodex-cache-usage.png b/.github/pr-assets/opencodex-cache-usage.png new file mode 100644 index 0000000000000000000000000000000000000000..6011c89b57f0f0d1fa2bb80245e2b336640fe934 GIT binary patch literal 232808 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

t^Olig$9+RtotI@%<(3l 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%0Dn#IDohVpojq? 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 and then push a + canonical `CODEX_REASONING_LEVELS` rung into it, which also carries + `description`. That has always been a type error; it was invisible because + `reasoning-effort.ts` → `providers/reasoning-metadata.ts` → + `providers/key-store.ts` → the `../config` barrel formed an import cycle, + and inside it the rung type degraded so the excess-property check never ran. + Carried #5145 breaks that cycle on purpose — its new `api-key-resolve.ts` is + a leaf module written so reasoning-metadata can import it without the barrel + — so the latent error surfaced on this branch first. Neither file is in this + lane's scope and neither is touched by its diff; the fix is the remedy + `AGENTS.md` prescribes for a restated shape: `reasoning-effort.ts` now + exports `CodexReasoningLevel`, and the three casts derive + `Array>` from it instead of restating a + narrower literal. Any lane that breaks this cycle would have hit the same + wall. +- `test 4/4` (`production adapter contract rejects omitted translator budgets + at typecheck`) spawns tsc over the project and asserts the valid fixture + exits zero. It was downstream of the same five errors and needs no change of + its own. +- `test 1/4` (`Cursor catalog discovery cooldown > second refresh during + cooldown does not re-invoke discovery`) was a real regression from this + lane. Scoping only the roster reads to the credential left the failure + cooldown provider-wide, so the branch had to require a credential-scoped + stale entry before honouring it — and a discovery that fails before caching + anything has no stale entry, which reopened the timeout storm #54 closed. + The fix moves the scope to where the observation actually belongs: a + discovery failure now records the credential that observed it, and + `isModelsFetchCoolingDown` suppresses only that credential. A failure + recorded without an identity stays credential-agnostic and suppresses + everyone, so plain-endpoint providers and the existing Qoder branch keep + their current behaviour unchanged. This is the same thesis as the rest of the + bundle: one account's 401 or 404 is not evidence about another account's + catalog. `cursor-roster-account-scope.test.ts` already pins both halves. +- `macos 1/2` carried the same shard failures as the Linux shards. + +The branch is now aligned on `dev` at `447ac22ca6` (lanes B and E landed). +Lane E's `run-turn-queue.ts` and `admission-model-scope.ts` do not overlap +this lane's surface; the merge was clean. + +Run 35496444256 at `6bd074e0` confirmed both fixes: `test 1/4` and `test 4/4` +passed, along with every other shard, both macOS shards, `structure gate`, +`docker smoke`, `docs site build`, `api usage`, `storage policy`, keyring on +all three platforms and `npm-global` on all three. Only `gates` still failed, +on three GUI assertions, all of them the same restated-literal class and all +introduced by the carried #4793 columns: + +- `usage-custom-range` listed the models-table headers as English literals and + omitted the `API list-price` column the page already renders, so the case + could not pass on any tree carrying both. The expectation now maps the + ordered column keys through the `en` catalog, which is where that copy lives. +- The French accidental-English guard and the zh-TW stale-placeholder guard + both flagged `usage.unavailable`, whose value is an em dash. Adding one more + allowlist entry would have been literal-for-literal, so both checks now + derive the rule from the value: with placeholders removed, a string carrying + no letters has nothing to translate and is identical in every locale by + construction. Keys that do carry letters, `uptime.hour` among them, stay + allowlisted and still fail if they go untranslated. + +## Verification + +### The macOS sideband failure was shard composition, not the relay + +`macos 2/2` then failed twice on `sideband GET /v1/live/{callId} relays the exact frame +ceiling bidirectionally`, and it is worth being precise about why, because retrying it would +not have helped and neither would touching its deadline. + +The case relays a 50 MiB WebSocket frame end to end against a hard 15s deadline. It is not in +`SERIAL_FULL_SUITE_FILES`, so it runs inside `bun test --shard=N/2` sharing one process with +the rest of that half. On `dev` at `043aa435f` it lands in shard 1 and its echo leg alone +takes **7.4s of the 15s budget**. This branch adds three test files in unrelated directories, +Bun repartitioned the halves, `tests/server/server-live.test.ts` moved to shard 2, and the +echo leg went past 15s on both attempts while the peer never received the frame +(`recv=13 progress=5 moving=no`). Nothing in this lane's diff touches the sideband relay, the +live route or WebSocket handling, and the delta between the run that passed every shard and +the run that failed this one is three GUI test files and a devlog page. + +So the test has been passing by accident: its result was a property of which half it drew. +The remedy is the mechanism the repository already has for this exact category — +`SERIAL_FULL_SUITE_FILES`, described in its own guard as quarantining *load-sensitive* files +into one-worker lanes. Adding `server/server-live.test.ts` there keeps the 15s deadline, keeps +the assertion, and keeps macOS in the matrix; it only stops the case from sharing a process. +It also takes the landmine out of the path of the next lane that adds a test file anywhere in +the tree. If the coordinator would rather own that change centrally, it is one line in +[scripts/test.ts](../../../scripts/test.ts) and can be lifted out of this branch. + +Per batch rules, no local suites, individual tests, typecheck, build, +install or live `ocx` execution. Verification is static source review plus +exact-head hosted CI. + +- NOT RUN: `bun run test`, focused `bun test`, `bun run typecheck`, + `bun run lint:gui`, `bun run build:gui`, `bun run privacy:scan`, + `bun run structure:check` (all forbidden locally; hosted CI decides). +- Static checks performed: file-size ratchet evaluated against + `tests/fixtures/file-size-baseline.json` (the carried routing test would + have grown 83 lines over its cap — moved to a registered sibling; all new + files are far below the 2000-line threshold); both layout registries carry + every new test file and parse as JSON; the ten GUI locale catalogs gained + identical keys (no hand-restated roster or count); the diagnostic module's + imports were walked for a `src/lab/` reach (none) and `responses/core.ts` + gains no runtime import of it. +- Focused regression tests added next to the existing subsystem tests: + roster credential binding (Cursor, Devin), cancelled-refresh fencing + (admission + main-account refresh), measured-zero survival (usage + passthrough), and the diagnostic itself (privacy, fingerprints, alias + rebinding, retention, tag independence from affinity-debug). diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 5d2b9f3a266..3afb7af916f 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -720,6 +720,22 @@ See [The parser and bridge](/reference/architecture/#the-parser) for the explici There is no provider-level setting that can add a missing `tool_search` declaration; ordinary code-mode discovery remains a separate path. +### Cache-read diagnostics + +Set `OPENCODEX_CACHE_DEBUG=1` before starting the proxy to write one diagnostic record per +finalized request to `/cache-debug.jsonl`. The switch is off by default; set it to `0` +or remove it to disable capture. The file is owner-only (`0600`) in the hardened config directory +and rolls after 200 lines, retaining the newest 100. + +Each JSONL record contains the protocol, routed provider/model, cache-counter presence and +provenance, process-local equality tags for the account, prompt-cache key, and allowlisted session +headers, plus ordered fingerprints for instructions, tools, and message/input blocks. Prefix +sections retain at most 128 tags and identify only the first divergent section/index. The +diagnostic never stores prompt or message text, tool names, raw headers, raw cache/session/account +identifiers, or a durable tag derived from them. Its random HMAC key is created at process start, +separate from other debug keys, and is never persisted; tags therefore compare values only within +one proxy process. + ### Catalog troubleshooting If a model is missing from Codex, or the catalog order/visibility looks wrong, check in order: diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index ed8f4cb709c..72c1a821d96 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -93,7 +93,7 @@ badge or the version value to read the full value. | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. The page distinguishes a catalog saved on the hub, a catalog fetched by this client, and activation in a running client. A fetch timestamp does not prove it includes the latest hub save, and runtime activation is shown as unverified. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | -| **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | +| **Usage / Debug** | Inspect token-usage coverage and trends. The Usage page's Models table also breaks each model down into input tokens, output tokens, cache hits, cache writes, and cache hit rate; a dash means cache telemetry for that metric is unavailable. Or enable opt-in provider transport and usage-extraction diagnostics. | | **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1f75761045e..1712170d5bf 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -980,6 +980,12 @@ export const de: Record = { "usage.col.requests": "Anfragen", "usage.col.measured": "Gemessen", "usage.col.reported": "Gemeldet", + "usage.col.inputTokens": "Eingabe-Tokens", + "usage.col.outputTokens": "Ausgabe-Tokens", + "usage.col.cacheHits": "Cache-Treffer", + "usage.col.cacheWrites": "Cache-Schreibvorgänge", + "usage.col.cacheHitRate": "Trefferquote", + "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "API-Listenpreis", "usage.col.share": "Anteil", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 4146debb96d..ab3a7dc8d6b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1033,6 +1033,12 @@ export const en = { "usage.col.requests": "Requests", "usage.col.measured": "Measured", "usage.col.reported": "Reported", + "usage.col.inputTokens": "Input tokens", + "usage.col.outputTokens": "Output tokens", + "usage.col.cacheHits": "Cache hits", + "usage.col.cacheWrites": "Cache writes", + "usage.col.cacheHitRate": "Hit rate", + "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "API list-price", "usage.col.share": "Share", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1153c7329b2..1cc6e4e5265 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1010,6 +1010,12 @@ export const fr: Record = { "usage.col.requests": "Requêtes", "usage.col.measured": "Mesurées", "usage.col.reported": "Communiquées", + "usage.col.inputTokens": "Jetons d’entrée", + "usage.col.outputTokens": "Jetons de sortie", + "usage.col.cacheHits": "Lectures du cache", + "usage.col.cacheWrites": "Écritures dans le cache", + "usage.col.cacheHitRate": "Taux de succès du cache", + "usage.unavailable": "—", "usage.col.tokens": "Jetons", "usage.col.apiListPrice": "Tarif catalogue API", "usage.col.share": "Part", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 64a68f59419..b61caac4a61 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -945,6 +945,12 @@ export const ja: Record = { "usage.col.requests": "リクエスト", "usage.col.measured": "計測", "usage.col.reported": "報告", + "usage.col.inputTokens": "入力トークン", + "usage.col.outputTokens": "出力トークン", + "usage.col.cacheHits": "キャッシュヒット", + "usage.col.cacheWrites": "キャッシュ書き込み", + "usage.col.cacheHitRate": "ヒット率", + "usage.unavailable": "—", "usage.col.tokens": "トークン", "usage.col.apiListPrice": "API 定価", "usage.col.share": "割合", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d5a4a9392c5..68b1a561a65 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1014,6 +1014,12 @@ export const ko: Record = { "usage.col.requests": "요청", "usage.col.measured": "측정됨", "usage.col.reported": "측정됨", + "usage.col.inputTokens": "입력 토큰", + "usage.col.outputTokens": "출력 토큰", + "usage.col.cacheHits": "캐시 히트", + "usage.col.cacheWrites": "캐시 쓰기", + "usage.col.cacheHitRate": "히트율", + "usage.unavailable": "—", "usage.col.tokens": "토큰", "usage.col.apiListPrice": "API 정가", "usage.col.share": "비율", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 631637977eb..440958324df 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1001,6 +1001,12 @@ export const ru: Record = { "usage.col.requests": "Запросы", "usage.col.measured": "Измерено", "usage.col.reported": "Сообщено", + "usage.col.inputTokens": "Входные токены", + "usage.col.outputTokens": "Выходные токены", + "usage.col.cacheHits": "Попадания в кэш", + "usage.col.cacheWrites": "Записи в кэш", + "usage.col.cacheHitRate": "Доля попаданий", + "usage.unavailable": "—", "usage.col.tokens": "Токены", "usage.col.apiListPrice": "Прайс-лист API", "usage.col.share": "Доля", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1646ccb8251..0707038237e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1020,6 +1020,12 @@ export const tr: Record = { "usage.col.requests": "İstekler", "usage.col.measured": "Ölçülen", "usage.col.reported": "Bildirilen", + "usage.col.inputTokens": "Girdi jetonları", + "usage.col.outputTokens": "Çıktı jetonları", + "usage.col.cacheHits": "Önbellek isabetleri", + "usage.col.cacheWrites": "Önbellek yazma", + "usage.col.cacheHitRate": "İsabet oranı", + "usage.unavailable": "—", "usage.col.tokens": "Jetonlar", "usage.col.apiListPrice": "API liste fiyatı", "usage.col.share": "Pay", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 3d726cc35e7..9af7198a8be 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1003,6 +1003,12 @@ export const vi: Record = { "usage.col.requests": "Yêu cầu", "usage.col.measured": "Đã đo", "usage.col.reported": "Đã báo cáo", + "usage.col.inputTokens": "Token đầu vào", + "usage.col.outputTokens": "Token đầu ra", + "usage.col.cacheHits": "Lượt truy cập cache", + "usage.col.cacheWrites": "Lần ghi cache", + "usage.col.cacheHitRate": "Tỷ lệ truy cập cache", + "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "Giá niêm yết API", "usage.col.share": "Tỷ trọng", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index cb4513ba7ef..01799d6b8b1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -820,6 +820,12 @@ export const zhTW: Record = { "usage.col.requests": "請求數", "usage.col.measured": "已計量", "usage.col.reported": "已上報", + "usage.col.inputTokens": "輸入 Token", + "usage.col.outputTokens": "輸出 Token", + "usage.col.cacheHits": "快取命中", + "usage.col.cacheWrites": "快取寫入", + "usage.col.cacheHitRate": "命中率", + "usage.unavailable": "—", "usage.col.tokens": "Token 數", "usage.col.apiListPrice": "API 標價", "usage.col.share": "佔比", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index aa5049f34ce..0f0e06ca155 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -995,6 +995,12 @@ export const zh: Record = { "usage.col.requests": "请求数", "usage.col.measured": "已计量", "usage.col.reported": "已上报", + "usage.col.inputTokens": "输入 Token", + "usage.col.outputTokens": "输出 Token", + "usage.col.cacheHits": "缓存命中", + "usage.col.cacheWrites": "缓存写入", + "usage.col.cacheHitRate": "命中率", + "usage.unavailable": "—", "usage.col.tokens": "Token 数", "usage.col.apiListPrice": "API 标价", "usage.col.share": "占比", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index de96b16e115..6c059b329b1 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -67,6 +67,12 @@ interface UsageModel { totalTokens: number; inputTokens: number; outputTokens: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + cacheHitRate?: number | null; + /** Input tokens whose cache detail was observed; hit rate is not model-wide below inputTokens. */ + cacheObservedInputTokens?: number; /** API list-price estimate for the priced portion of this row. */ estimatedCostUsd?: number; /** Requests included in the API list-price estimate. */ @@ -160,6 +166,16 @@ function UsageListPrice({ row, locale, t }: { row: UsageCostRow; locale: Locale; ); } +function formatOptionalTokens(value: number | undefined, locale: Locale, unavailable: string): string { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? formatTokens(value, locale) + : unavailable; +} + +function formatOptionalPct(value: number | null | undefined, unavailable: string): string { + return typeof value === "number" && Number.isFinite(value) ? formatPct(value) : unavailable; +} + // Stable per-model bar color: hash the provider/model id to a hue so the same model keeps its color // across days and renders. Saturation/lightness are fixed for a cohesive palette on the dark chart. function modelColor(model: string, provider: string): string { @@ -695,6 +711,7 @@ function UsageModelsTable({ const sectionLabel = t("usage.section.models"); const titleId = "usage-models-title"; const listPriceDisclaimerId = "usage-models-list-price-disclaimer"; + const unavailable = t("usage.unavailable"); const searchInput = ( {t("logs.col.provider")} {t("usage.col.requests")} {t("usage.col.measured")} + {t("usage.col.inputTokens")} + {t("usage.col.outputTokens")} + {t("usage.col.cacheHits")} + {t("usage.col.cacheWrites")} + {t("usage.col.cacheHitRate")} {t("usage.col.tokens")} {t("usage.col.apiListPrice")} {t("usage.col.share")} @@ -725,6 +747,18 @@ function UsageModelsTable({ {formatProviderDisplayName(model.provider, t)} {model.requests} {model.measuredRequests} + {formatTokens(model.inputTokens, locale)} + {formatTokens(model.outputTokens, locale)} + {formatOptionalTokens(model.cacheReadInputTokens ?? model.cachedInputTokens, locale, unavailable)} + {formatOptionalTokens(model.cacheCreationInputTokens, locale, unavailable)} + {formatOptionalPct( + typeof model.cacheObservedInputTokens === "number" + && Number.isFinite(model.cacheObservedInputTokens) + && model.cacheObservedInputTokens >= model.inputTokens + ? model.cacheHitRate + : null, + unavailable, + )} {formatTokens(model.totalTokens, locale)}

diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 5c5d8aa73d8..64b6bcb4a4e 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -13,6 +13,17 @@ import { statusCodeInfo } from "../src/status-codes"; const FR_CATALOG_URL = new URL("../src/i18n/fr.ts", import.meta.url); const PLACEHOLDER_RE = /\{([a-zA-Z0-9_]+)\}/g; +/** + * A value carrying no letters once its placeholders are removed has nothing to translate. + * An em dash, a unit symbol and "{position} / {total}" are identical in every locale by + * construction, so matching English is evidence of nothing. Deriving that from the value + * keeps the allowlist below for real words instead of growing it each time the UI gains + * another symbol. + */ +function carriesTranslatableWords(value: string): boolean { + return /\p{L}/u.test(value.replace(/\{[a-zA-Z0-9_]+\}/g, " ")); +} + const INTENTIONAL_ENGLISH = new Set([ // Units, symbols, protocol values, machine labels, and product names. "integrations.cursor.noControl", @@ -223,7 +234,9 @@ describe("French base catalog", () => { const french = (await import("../src/i18n/fr")).fr; const accidental = (Object.keys(DICTS.en) as TKey[]).filter(key => - french[key] === DICTS.en[key] && !INTENTIONAL_ENGLISH.has(key) + french[key] === DICTS.en[key] + && !INTENTIONAL_ENGLISH.has(key) + && carriesTranslatableWords(String(DICTS.en[key])) ); expect(accidental).toEqual([]); diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index cc602a8a9ce..acc20c98140 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -14,6 +14,16 @@ async function readDict(locale: string): Promise> { return out; } +/** + * A value with no letters once its placeholders are removed has nothing to translate: an em + * dash, a currency template, a bare glyph. Matching English there is evidence of nothing, so + * it is derived from the value instead of growing the allowlist by one entry every time the + * UI gains another symbol. + */ +function carriesTranslatableWords(value: string): boolean { + return /\p{L}/u.test(value.replace(/\{[a-zA-Z0-9_]+\}/g, " ")); +} + // When the English locale grows, `scripts/sync-locale-keys.mjs` seeds the new key into every // locale as an English placeholder so the build does not break. That is fine for de/ko/ja/zh/ru // (they each have a human owner who will translate later), but zh-TW is this PR's contribution @@ -192,6 +202,7 @@ test("zh-TW ships no untranslated English placeholders beyond the intentional al const enValue = en.get(key); if (enValue === undefined) continue; // key-set parity is the other test's job if (!value.trim()) continue; // blank-value is the other test's job + if (!carriesTranslatableWords(value)) continue; // a symbol is identical in every locale if (value === enValue && !ZH_TW_KEEP_ENGLISH.has(key)) { stale.push(key); } diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 3ef6373ccfe..47cb31202ff 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -4,6 +4,7 @@ import { resolve } from "node:path"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; +import { en } from "../src/i18n/en"; import { clearClientResourceStoresForTests } from "../src/client-resource"; import Usage from "../src/pages/Usage"; @@ -81,6 +82,65 @@ function report(gate: RequestGate, marker: string, date = "2020-09-15") { }; } +test("Usage model table renders cache breakdown and marks unavailable telemetry", async () => { + await mount(); + const data = report(requests[0], "cache-model"); + data.models = [ + { + ...data.models[0]!, + model: "cache-model", + totalTokens: 1_120, + inputTokens: 1_000, + outputTokens: 120, + cachedInputTokens: 600, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 100, + cacheHitRate: 0.6, + cacheObservedInputTokens: 1_000, + }, + { + ...data.models[0]!, + model: "partial-cache-model", + totalTokens: 1_000, + inputTokens: 1_000, + outputTokens: 0, + cachedInputTokens: 450, + cacheReadInputTokens: 450, + cacheCreationInputTokens: 0, + cacheHitRate: 0.9, + cacheObservedInputTokens: 500, + }, + { + ...data.models[0]!, + model: "unknown-cache-model", + totalTokens: 110, + inputTokens: 100, + outputTokens: 10, + }, + ]; + await act(async () => { requests[0]!.resolve(Response.json(data)); }); + + const table = container.querySelector("#usage-section-models table"); + expect(table).not.toBeNull(); + // Header labels come from the catalog the page renders, so a copy change stays a + // one-place edit and this case keeps asserting the column ORDER it cares about -- + // the five cache columns sitting between Measured and Tokens. + expect([...table!.querySelectorAll("thead th")].map(cell => cell.textContent?.trim())).toEqual([ + "logs.col.model", "logs.col.provider", "usage.col.requests", "usage.col.measured", + "usage.col.inputTokens", "usage.col.outputTokens", "usage.col.cacheHits", + "usage.col.cacheWrites", "usage.col.cacheHitRate", "usage.col.tokens", + "usage.col.apiListPrice", "usage.col.share", + ].map(key => en[key as keyof typeof en])); + const rows = table!.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(3); + const measured = [...rows[0]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); + expect(measured?.slice(4, 9)).toEqual(["1000", "120", "600", "100", "60%"]); + const partial = [...rows[1]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); + expect(partial?.slice(6, 9)).toEqual(["450", "0", "—"]); + const unavailable = [...rows[2]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); + expect(unavailable?.slice(6, 9)).toEqual(["—", "—", "—"]); +}); + async function respond(index: number, marker: string, date?: string) { await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); }); } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 74b27f8859c..7bbb92e15db 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -296,6 +296,7 @@ "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", "bun-stream-caps.test.ts": "lib", + "cache-diagnostic.test.ts": "usage", "cancel-body-on-abort.test.ts": "server", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", "catalog-cursor-search.test.ts": "codex-integration", @@ -522,6 +523,8 @@ "codex-management-convergence.test.ts": "codex-integration", "codex-metadata-integrity.test.ts": "codex-integration", "codex-model-entitlements.test.ts": "codex-integration", + "codex-model-entitlement-admission.test.ts": "codex-integration", + "codex-routing-cache-affinity-detour.test.ts": "codex-integration", "codex-model-denial-evidence.test.ts": "codex-integration", "codex-model-availability-error.test.ts": "codex-integration", "codex-models-cache-invalidate.test.ts": "codex-integration", @@ -617,6 +620,7 @@ "credential-redirect-guard.test.ts": "lib", "crusoe-provider.test.ts": "providers", "cursor-adapter.test.ts": "providers/cursor", + "cursor-roster-account-scope.test.ts": "providers/cursor", "cursor-continuity-retention.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", diff --git a/scripts/test.ts b/scripts/test.ts index 4406b080867..dc95434b782 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -352,6 +352,15 @@ export const SERIAL_FULL_SUITE_FILES = [ "adapters/openai/openai-provider-option-e2e.test.ts", "ci-workflows/release-helper.test.ts", "update/update-stop-first.test.ts", + // Relays a 50 MiB WebSocket frame end to end against a 15s deadline, so its result is a + // measurement of the whole process, not of the relay. On a healthy 3-CPU macOS runner the + // echo leg alone spends 7.4s of that budget; whichever half of `--shard=N/2` it lands in + // decides whether it finishes. It has been passing by accident: it sat in the lighter half + // until three unrelated test files were added elsewhere in the tree, Bun repartitioned, and + // it went from 7.4s to over 15s twice in a row without anything on the sideband path + // changing. Quarantining it here is what keeps it a test of the relay instead of a test of + // its neighbours. + "server/server-live.test.ts", ] as const; type SerialLaneBasename = (typeof SERIAL_FULL_SUITE_FILES)[number] extends infer P diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 17f9fd45fd7..e495f2a6713 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -61,6 +61,7 @@ import { CursorTransportDisabledError, type CursorTransportFactory, } from "./cursor/transport"; +import { cursorLiveRosterScope } from "./cursor/catalog"; export const CURSOR_API_URL = "https://api2.cursor.sh"; @@ -172,9 +173,11 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda // Namespace thread→conversation derivation by the authenticated Cursor credential so // shared-proxy tenants with different Cursor accounts cannot collide on a parent thread id. // Prefer an already-set auth scope (e.g. Codex pool account) when present. + let liveRosterScope: string | undefined; if (!_parsed._cursorIdentityScope) { try { const token = resolveCursorToken(provider, incoming.headers); + liveRosterScope = cursorLiveRosterScope(provider.baseUrl, token); _parsed._cursorIdentityScope = createHash("sha256") .update("ocx:cursor:acct:") .update(token) @@ -183,11 +186,17 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } catch { /* Missing credential is handled by the live transport path below. */ } + } else { + try { + liveRosterScope = cursorLiveRosterScope(provider.baseUrl, resolveCursorToken(provider, incoming.headers)); + } catch { + /* Missing credential is handled by the live transport path below. */ + } } const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; const previousConversationId = _parsed._cursorConversationId; let request = { - ...createCursorRequest(_parsed), + ...createCursorRequest(_parsed, { liveRosterScope }), _cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local", }; requestSizeContext = cursorRequestSizeContext(request); @@ -441,7 +450,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda lastTransport = undefined; _parsed._cursorConversationId = undefined; const next = { - ...createCursorRequest(_parsed, { forceFreshConversation: true }), + ...createCursorRequest(_parsed, { forceFreshConversation: true, liveRosterScope }), _cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local", }; rekeyContextUsage(failedConversationId, next.conversationId); diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index 69791f087b8..d6a73defe26 100644 --- a/src/adapters/cursor/catalog.ts +++ b/src/adapters/cursor/catalog.ts @@ -3,6 +3,7 @@ import { normalizeCursorClaudeId, type NormalizedCursorClaudeId, } from "./claude-id"; +import { createHash } from "node:crypto"; /** * Cursor umbrella catalog — the single source of truth for cursor model @@ -599,7 +600,7 @@ export function resolveCursorSelection( pickedId: string, reasoning: string | undefined, liveMaxModeIds?: ReadonlySet, - options: { fast?: boolean } = {}, + options: { fast?: boolean; liveRosterScope?: string } = {}, ): CursorResolvedSelection { const parsed = parseCursorVariantId(pickedId); if (!parsed.known) { @@ -617,7 +618,10 @@ export function resolveCursorSelection( const requested = parsed.level ?? reasoning; const effort = cursorVariantEffort(spec, requested); const requestedClaude = normalizeCursorClaudeId(pickedId); - const claudeIdentity = liveCursorClaudeWireIdentities.get(parsed.baseId) + const scopedClaudeIdentities = options.liveRosterScope + ? liveCursorClaudeWireIdentitiesByScope.get(options.liveRosterScope) + : liveCursorClaudeWireIdentities; + const claudeIdentity = scopedClaudeIdentities?.get(parsed.baseId) ?? (requestedClaude ? { sourceBaseId: requestedClaude.sourceBaseId, spelling: requestedClaude.spelling } : undefined); @@ -626,7 +630,9 @@ export function resolveCursorSelection( ? `${capability.wirePrefix}${canonicalId}` : canonicalId; const ultraRequested = parsed.ultra || reasoning?.toLowerCase() === "ultra"; - const evidence = liveMaxModeIds ?? liveCursorMaxModeBases; + const evidence = liveMaxModeIds + ?? (options.liveRosterScope ? liveCursorMaxModeBasesByScope.get(options.liveRosterScope) : undefined) + ?? liveCursorMaxModeBases; const maxModeArmed = capability.maxModeVerified === true || evidence.has(parsed.baseId); return { wireId, canonicalId, maxMode: ultraRequested && maxModeArmed, known: true }; } @@ -639,15 +645,36 @@ export function resolveCursorSelection( */ let liveCursorMaxModeBases: ReadonlySet = new Set(); let liveCursorClaudeWireIdentities: ReadonlyMap = new Map(); +const liveCursorMaxModeBasesByScope = new Map>(); +const liveCursorClaudeWireIdentitiesByScope = new Map>(); +const liveCursorRosterScopesByProvider = new Map>(); + +/** Non-secret key binding live roster evidence to one upstream destination and credential. */ +export function cursorLiveRosterScope(baseUrl: string | undefined, credential: string): string { + const destination = (baseUrl?.trim().replace(/\/+$/, "") || "https://api2.cursor.sh"); + return createHash("sha256") + .update("ocx:cursor:live-roster\0") + .update(destination) + .update("\0") + .update(credential) + .digest("hex"); +} -export function recordLiveCursorClaudeModels(liveIds: readonly string[]): void { +export function recordLiveCursorClaudeModels(liveIds: readonly string[], scope?: { provider: string; key: string }): void { const next = new Map(); for (const rawId of liveIds) { const n = normalizeCursorClaudeId(rawId.startsWith("cursor-") ? rawId.slice(7) : rawId); if (!n || !CURSOR_CAPABILITIES[n.canonicalBaseId]) continue; if (!next.has(n.canonicalBaseId)) next.set(n.canonicalBaseId, { sourceBaseId: n.sourceBaseId, spelling: n.spelling }); } - liveCursorClaudeWireIdentities = next; + if (scope) { + liveCursorClaudeWireIdentitiesByScope.set(scope.key, next); + const scopes = liveCursorRosterScopesByProvider.get(scope.provider) ?? new Set(); + scopes.add(scope.key); + liveCursorRosterScopesByProvider.set(scope.provider, scopes); + } else { + liveCursorClaudeWireIdentities = next; + } } export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap { @@ -656,15 +683,32 @@ export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap(); for (const id of liveIds) { const parsed = parseCursorVariantId(id); if (parsed.known) bases.add(parsed.baseId); } - liveCursorMaxModeBases = bases; + if (scope) liveCursorMaxModeBasesByScope.set(scope.key, bases); + else liveCursorMaxModeBases = bases; +} + +export function clearLiveCursorRosterState(provider?: string): void { + if (!provider) { + liveCursorClaudeWireIdentitiesByScope.clear(); + liveCursorMaxModeBasesByScope.clear(); + liveCursorRosterScopesByProvider.clear(); + return; + } + for (const scope of liveCursorRosterScopesByProvider.get(provider) ?? []) { + liveCursorClaudeWireIdentitiesByScope.delete(scope); + liveCursorMaxModeBasesByScope.delete(scope); + } + liveCursorRosterScopesByProvider.delete(provider); } export function liveCursorMaxModeBasesForTests(): ReadonlySet { diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 3c0e5faa802..ec4fa07208c 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -213,7 +213,7 @@ export function cursorRequestEmitsFastVariant(parsed: OcxParsedRequest): boolean * instead: current Cursor clients send the matching Grok base id plus `effort` and `fast` parameters. * A fully-qualified id (one that is not a known effort base) passes through unchanged. */ -function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: boolean): { +function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: boolean, liveRosterScope?: string): { modelId: string; requestedModelParameters?: readonly CursorRequestedModelParameter[]; routingLevel?: CursorRoutingLevel; @@ -239,7 +239,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: bool ], }; } - const resolved = resolveCursorSelection(id, reasoning, undefined, { fast }); + const resolved = resolveCursorSelection(id, reasoning, undefined, { fast, liveRosterScope }); return { ...selection, ...(resolved.maxMode ? { maxMode: true } : {}), @@ -410,6 +410,8 @@ export function cursorCoveredPrefixDigest(parsed: OcxParsedRequest, coveredMessa export interface CreateCursorRequestOptions { /** Force a brand-new Cursor conversation id even when remembered state exists. */ forceFreshConversation?: boolean; + /** Credential-bound scope for live Cursor model spelling and Max-Mode evidence. */ + liveRosterScope?: string; } function lookupPrefixSnapshot( @@ -502,7 +504,12 @@ export function createCursorRequest( const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice); const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice); const limitNote = catalogLimitNote(budget.tools, budget.omitted); - const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning, cursorFastRequested(parsed)); + const model = normalizeCursorModelId( + parsed.modelId, + parsed.options.reasoning, + cursorFastRequested(parsed), + options.liveRosterScope, + ); const request: CursorRunRequest = { modelId: model.modelId, ...(model.requestedModelParameters ? { requestedModelParameters: model.requestedModelParameters } : {}), diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 83ce6e1517b..cadcb2a38c2 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -42,6 +42,7 @@ import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-ca import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward"; import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; +import { observeOutbound } from "../../usage/cache-diagnostic"; /** * Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need @@ -501,6 +502,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // here, on the serialized body, not on the parsed selector. One place covers both the // HTTP and the WebSocket outbound, because the WS path transports this same request // instead of rebuilding it. + observeOutbound(parsed._rawBody, finalBody, headers); const body = JSON.stringify(finalBody); const releaseBodyObservation = translatorBudget.observeExternallyCapped( "passthrough_serialization", diff --git a/src/cli/index.ts b/src/cli/index.ts index e689bd26f36..d65ce7dc962 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -597,11 +597,11 @@ async function handleStart(options: { block?: boolean } = {}) { try { const { fetchAllModels } = await import("../server/management-api"); const { desktopVisibleNativeSlugs } = await import("../codex/catalog"); - const { resolveCodexModelEntitlements } = await import("../codex/model-entitlements"); + const { resolveAdmittedCodexModelEntitlements } = await import("../codex/model-entitlement-admission"); const { buildDesktopDiscoveryInputs } = await import("../claude/desktop-discovery-inputs"); const [models, modelEntitlements] = await Promise.all([ fetchAllModels(config), - resolveCodexModelEntitlements(config, { clientVersion: null }), + resolveAdmittedCodexModelEntitlements(config, { clientVersion: null }), ]); const inputs = buildDesktopDiscoveryInputs({ config, models, modelEntitlements, diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index c9ca8e1ec7b..0593a3fee03 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -1,4 +1,4 @@ -import { CODEX_REASONING_LEVELS } from "../../reasoning-effort"; +import { CODEX_REASONING_LEVELS, type CodexReasoningLevel } from "../../reasoning-effort"; import { clearModelCache } from "../model-cache"; import { routedSlug, slugEquivalenceKey } from "../../providers/slug-codec"; import { COMBO_NAMESPACE } from "../../combos"; @@ -872,7 +872,7 @@ export function mergeCatalogEntriesFromObservedState({ // (luna: no ultra) stay intact. if (!freshCustomEntries.has(m) && !exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { const levels = Array.isArray(e.supported_reasoning_levels) - ? e.supported_reasoning_levels as Array<{ effort?: string }> + ? e.supported_reasoning_levels as Array> : []; if (levels.length > 0 && !suppressedSyntheticMaxSlugs.has(String(e.slug ?? "")) diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 4206983183b..96f8c0270e5 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -8,7 +8,7 @@ import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCa import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; -import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts, type CodexReasoningLevel } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry } from "../../providers/registry"; @@ -286,7 +286,7 @@ export function isGpt56NativeSlug(slug: string): boolean { export function ensureGpt56ReasoningLevels(entry: RawEntry): void { const levels = Array.isArray(entry.supported_reasoning_levels) - ? entry.supported_reasoning_levels as Array<{ effort?: string }> + ? entry.supported_reasoning_levels as Array> : []; const out = [...levels]; // max is a real native rung on the 5.6 family — always restored; ultra always advertised. @@ -300,7 +300,7 @@ export function ensureGpt56ReasoningLevels(entry: RawEntry): void { export function ensureUltraReasoningLevel(entry: RawEntry): void { const levels = Array.isArray(entry.supported_reasoning_levels) - ? entry.supported_reasoning_levels as Array<{ effort?: string }> + ? entry.supported_reasoning_levels as Array> : []; if (levels.length === 0) return; const wanted = ["max", "ultra"]; diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index d2641eba78d..3ff7f9abe5b 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -52,7 +52,7 @@ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { cursorLiveRosterScope, recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; import { fetchQoderModels } from "../../adapters/qoder/live-models"; import { resolveQoderProfile } from "../../adapters/qoder/profiles"; import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; @@ -289,15 +289,19 @@ export async function fetchProviderModelsWithAuth( } if (prov.adapter === "devin") { if (!apiKey) return observed(configured, "degraded"); - const cachedDevin = getFreshCached(name, ttlMs); + // Devin's usable-model list is entitlement-specific. Bind cache reads/writes to an + // irreversible credential fingerprint so a credential switch cannot observe another + // account's roster or stale fallback (the Qoder precedent above). + const authorityIdentity = createHash("sha256").update(apiKey).digest("hex"); + const cachedDevin = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (cachedDevin) { return observed( withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)), "authoritative", ); } - if (isModelsFetchCoolingDown(name)) { - const cooling = getStaleCached(name); + if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity)) { + const cooling = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, @@ -339,17 +343,17 @@ export async function fetchProviderModelsWithAuth( } as CatalogModel; }); const forCache = withConfiguredRetention(result, { retainComboTargets: false }); - if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { return observed(withConfiguredRetention(configured), "degraded"); } markProviderDiscoveryOk(name, liveResult.models.length); return observed(withConfiguredRetention(forCache), "authoritative"); } if (isCurrentCacheGeneration()) { - markModelsFetchFailure(name); + markModelsFetchFailure(name, undefined, authorityIdentity); markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" }); } - const stale = getStaleCached(name); + const stale = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured), "degraded", @@ -361,15 +365,19 @@ export async function fetchProviderModelsWithAuth( // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort // suffix) but filter the static seed to the bases the account actually has — so models not on the // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed. - const cachedCursor = getFreshCached(name, ttlMs); + // The roster is entitlement-specific, so the cache entry is bound to an irreversible + // credential fingerprint: a credential switch must not observe the previous account's + // plan roster, its stale fallback, or its failure cooldown suppression. + const authorityIdentity = createHash("sha256").update(apiKey).digest("hex"); + const cachedCursor = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (cachedCursor) { return observed( withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias)), "authoritative", ); } - if (isModelsFetchCoolingDown(name)) { - const cooling = getStaleCached(name); + if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity)) { + const cooling = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured, @@ -394,27 +402,28 @@ export async function fetchProviderModelsWithAuth( // Cache the discovery-filtered roster without combo retention so a later // gather can re-apply the current capture's retain set on read. const forCache = withConfiguredRetention(result, { retainComboTargets: false }); - if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { return observed(withConfiguredRetention(configured), "degraded"); } // Publish roster-derived state only for a discovery the cache accepted: a stale // in-flight capture (generation revoked by a credential/config change) must not // overwrite the spelling or Max-Mode evidence of the newer one. - recordLiveCursorClaudeModels(liveResult.models); + const liveRosterScope = { provider: name, key: cursorLiveRosterScope(prov.baseUrl, apiKey) }; + recordLiveCursorClaudeModels(liveResult.models, liveRosterScope); // Live Max-Mode evidence feeds the umbrella resolver's ultra gate // (devlog 260828_cursor_umbrella_catalog; union with static evidence). - recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); + recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? [], liveRosterScope); markProviderDiscoveryOk(name, liveResult.models.length); return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); } if (isCurrentCacheGeneration()) { - markModelsFetchFailure(name); + markModelsFetchFailure(name, undefined, authorityIdentity); markProviderDiscoveryFailed(name, { reason: "provider" }); console.warn( `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, ); } - const staleCursor = getStaleCached(name); + const staleCursor = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured, diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 66e15186f29..efd3a63413e 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -15,9 +15,9 @@ import { availableAccountGatedNativeModels, codexModelEntitlementStateForAccount, isCodexModelEntitlementSnapshotCurrent, - resolveCodexModelEntitlements, type CodexModelEntitlementSnapshot, } from "../model-entitlements"; +import { resolveAdmittedCodexModelEntitlements } from "../model-entitlement-admission"; import { isAccountNeedsReauth } from "../account-runtime-state"; import { codexRuntimeStatePath } from "../runtime"; import { @@ -601,7 +601,7 @@ export async function syncCatalogModels( comboOmissions, providerModelOutcomes, }), - resolveCodexModelEntitlements(config), + resolveAdmittedCodexModelEntitlements(config), ]); const committed = withCatalogWriteSerialization(owningCodexHome, permit => { // Desired state can flip OFF during the provider await above. The catalog diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index c7f31ced057..212f4759593 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -78,9 +78,9 @@ import { availableAccountGatedNativeModels, codexModelEntitlementStateForAccount, isCodexModelEntitlementSnapshotCurrent, - resolveCodexModelEntitlements, type CodexModelEntitlementSnapshot, } from "./model-entitlements"; +import { resolveAdmittedCodexModelEntitlements } from "./model-entitlement-admission"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; import { providerCodexAccountMode } from "../providers/registry"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; @@ -434,7 +434,7 @@ export async function gatherCodexCatalogCandidate( providerModelOutcomes, discoveryPolicySnapshots: discoveryPolicies, }), - resolveCodexModelEntitlements(snapshot.config), + resolveAdmittedCodexModelEntitlements(snapshot.config), ]); const processLocal = processEvidence(source); const sourceEvidence = sealCatalogGatherEvidenceSession(session); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index e232055db23..09a11eaa86a 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -404,6 +404,12 @@ async function resolveMainAccountToken( : "transient" as const; throw new MainAccountTokenRefreshError(reason, { cause }); } + // The refresh may resolve after the caller went away (an implementation that does + // not observe the signal, or an abort landing in the window between resolution and + // commit). A cancelled request's late refresh must not rewrite auth.json on behalf + // of a request that no longer exists -- the same fence the reauth twin applies + // before its own commit above. + if (dependencies.signal?.aborted) throw dependencies.signal.reason; const result = persistRefreshedMainAuthJson(locked, refreshed); if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); return result; diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index c69d15abc9e..2da07787ec3 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -10,6 +10,7 @@ import type { CatalogModel } from "./catalog"; import type { GenerationContext } from "../lib/state-store-sweeper"; import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory"; +import { clearLiveCursorRosterState } from "../adapters/cursor/catalog"; /** Default freshness window. Matches Codex's own 5-min models cache so the two stay in step. */ export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000; @@ -80,7 +81,17 @@ function deleteCachedProvider(provider: string): number { * the full fetch timeout on every catalog poll (issue #54: UI stalls behind corporate proxies). */ export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000; -const failureAt = new Map(); +interface DiscoveryFailure { + at: number; + /** + * Credential the failure was observed under, for entitlement-specific rosters. Absent means + * the failure is credential-agnostic (a plain `/models` endpoint) and suppresses every + * caller, which is the original #54 behaviour. + */ + authorityIdentity?: string; +} + +const failureAt = new Map(); const discoveryStatus = new Map(); /** * How many models the last successful discovery actually returned, before any configured-alias @@ -91,8 +102,12 @@ const discoveryStatus = new Map(); const liveModelCounts = new Map(); let lastReconciledGeneration = 0; -export function markModelsFetchFailure(provider: string, now = Date.now()): void { - failureAt.set(provider, now); +export function markModelsFetchFailure( + provider: string, + now = Date.now(), + authorityIdentity?: string, +): void { + failureAt.set(provider, { at: now, ...(authorityIdentity ? { authorityIdentity } : {}) }); } /** `liveModelCount` is required so a caller that forgets to pass it fails typecheck instead of @@ -147,9 +162,25 @@ export function getProviderLiveModelCount(provider: string): number | undefined return liveModelCounts.get(provider); } -export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean { - const at = failureAt.get(provider); - return at !== undefined && now - at < cooldownMs; +/** + * Whether a failed discovery still suppresses the next one. + * + * `authorityIdentity` scopes the suppression to the credential that actually observed the + * failure. A roster that upstream filters per account is evidence about that account, and + * one account's 401 or 404 must not decide that a different account has no catalog. A + * failure recorded without an identity stays credential-agnostic and suppresses everyone, + * so the plain-endpoint providers keep the timeout protection #54 added. + */ +export function isModelsFetchCoolingDown( + provider: string, + cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, + now = Date.now(), + authorityIdentity?: string, +): boolean { + const failure = failureAt.get(provider); + if (failure === undefined || now - failure.at >= cooldownMs) return false; + if (failure.authorityIdentity === undefined || authorityIdentity === undefined) return true; + return failure.authorityIdentity === authorityIdentity; } /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */ @@ -240,6 +271,7 @@ export function clearModelCache( provider?: string, reason: ModelCacheClearReason = "authority", ): void { + clearLiveCursorRosterState(provider); const revokesInFlightDiscovery = reason === "authority"; if (provider) { if (revokesInFlightDiscovery) { diff --git a/src/codex/model-entitlement-admission.ts b/src/codex/model-entitlement-admission.ts new file mode 100644 index 00000000000..1b80fef2cc4 --- /dev/null +++ b/src/codex/model-entitlement-admission.ts @@ -0,0 +1,59 @@ +import type { OcxConfig } from "../types"; +import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { + resolveCodexModelEntitlements, + type CodexModelEntitlementResolveOptions, + type CodexModelEntitlementSnapshot, +} from "./model-entitlements"; +import { + type NativeMainCredentialAdmissionDeps, + withNativeMainCredentialAdmission, +} from "./native-main-admission"; + +interface ModelEntitlementAdmissionDeps extends NativeMainCredentialAdmissionDeps { + readonly resolve?: typeof resolveCodexModelEntitlements; +} + +function excludeNativeMain( + options: CodexModelEntitlementResolveOptions, +): CodexModelEntitlementResolveOptions { + return { + ...options, + excludeAccountIds: new Set([ + ...(options.excludeAccountIds ?? []), + MAIN_CODEX_ACCOUNT_ID, + ]), + }; +} + +/** + * Resolve background/data-plane entitlements inside the native-main fences. + * + * Pool discovery remains available when startup recovery or a profile drain + * owns the physical credential. When main is admitted, the process-local lease + * covers only the credential snapshot: getValidMainAccountToken holds the + * cross-process exclusive claim just for the auth.json refresh write, and the + * lease is released before the upstream entitlement request so a profile drain + * never waits on a network fetch. + */ +export async function resolveAdmittedCodexModelEntitlements( + config: Pick, + options: CodexModelEntitlementResolveOptions = {}, + deps: ModelEntitlementAdmissionDeps = {}, +): Promise { + const resolve = deps.resolve ?? resolveCodexModelEntitlements; + // A caller-supplied roster or an already-excluded main never reads auth.json, + // so there is no native credential to fence. + if (options.credentials || options.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)) { + return resolve(config, options); + } + return withNativeMainCredentialAdmission( + (excludedAccountIds, releaseMainLease) => resolve( + config, + excludedAccountIds.size === 0 + ? { ...options, releaseNativeMainCredentialLease: releaseMainLease } + : excludeNativeMain(options), + ), + { acquireNativeMain: deps.acquireNativeMain }, + ); +} diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index c9e1c8c8e0e..611ce115cfc 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -13,6 +13,7 @@ import { MAIN_CODEX_ACCOUNT_ID, type NativeMainRefreshDependencies, } from "./main-account"; +import { withNativeMainCredentialAdmission } from "./native-main-admission"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_GPT6_ASTRA_MODEL, @@ -439,6 +440,12 @@ export interface CodexModelEntitlementResolveOptions { readonly excludeAccountIds?: ReadonlySet; /** Ensure-only fence; ordinary request resolvers retain their established flight identity. */ readonly credentialMutationEpoch?: number; + /** + * Internal plumbing from `withNativeMainCredentialAdmission`: releases the + * native-main lifecycle lease once the credential phase settles, before any + * upstream roster fetch, so a profile drain never waits on network work. + */ + readonly releaseNativeMainCredentialLease?: () => void; } export interface CodexEntitlementFreshnessOptions extends Pick< @@ -452,6 +459,8 @@ export interface CodexEntitlementFreshnessOptions extends Pick< | "signal" > { readonly waitMs?: number; + /** Test seam for the native-main admission fence around the refresh workset. */ + readonly nativeMainCredentialAdmission?: typeof withNativeMainCredentialAdmission; } const accountModelsCache = new Map(); @@ -581,21 +590,31 @@ function currentCredentialIdentity(accountId: string): string | undefined { async function accountCredentialSnapshot( accountId: string, - options: Pick = {}, + options: Pick< + CodexModelEntitlementResolveOptions, + "nativeMainRefreshDependencies" | "releaseNativeMainCredentialLease" | "signal" + > = {}, ): Promise { if (accountId === MAIN_CODEX_ACCOUNT_ID) { - const token = await getValidMainAccountToken({ - signal: options.signal, - ...(options.nativeMainRefreshDependencies ?? {}), - }); - return token - ? { - accountId, - accessToken: token.accessToken, - chatgptAccountId: token.chatgptAccountId, - credentialIdentity: `main:${token.chatgptAccountId}`, - } - : null; + try { + const token = await getValidMainAccountToken({ + signal: options.signal, + ...(options.nativeMainRefreshDependencies ?? {}), + }); + return token + ? { + accountId, + accessToken: token.accessToken, + chatgptAccountId: token.chatgptAccountId, + credentialIdentity: `main:${token.chatgptAccountId}`, + } + : null; + } finally { + // The lifecycle lease fences only this credential read; releasing here — + // on success and on a credential-ownership failure alike — keeps a + // profile drain from waiting on the roster fetches that follow. + options.releaseNativeMainCredentialLease?.(); + } } try { const token = await getValidCodexToken(accountId); @@ -911,38 +930,58 @@ async function refreshCodexEntitlementWorkset( 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, - }); - } + const run = async ( + excludedAccountIds: ReadonlySet, + releaseMainLease?: () => void, + ): Promise => { + // An excluded main is filtered before the snapshot phase, not just before the + // roster fetch: it never produces an absence observation, so a denied + // admission cannot memoize a credential read that never happened. + const admittedWorkset = excludedAccountIds.size === 0 + ? workset + : workset.filter(accountId => !excludedAccountIds.has(accountId)); + const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot; + const observations = await Promise.all(admittedWorkset.map(async accountId => { + const credential = await credentialSnapshot(accountId, { + ...options, + releaseNativeMainCredentialLease: releaseMainLease, + }); + return { + accountId, + credential, + absenceObservedAt: options.now ?? Date.now(), + }; + })); + // The lease fences only the credential phase; release before roster fetches + // so a profile drain never waits on upstream network work. + releaseMainLease?.(); + 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, - }); - } + 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, + }); + } + }; + if (!workset.includes(MAIN_CODEX_ACCOUNT_ID)) return run(new Set()); + const admission = options.nativeMainCredentialAdmission ?? withNativeMainCredentialAdmission; + return admission(run); } function waitForEntitlementEnsureFlight( @@ -1110,6 +1149,10 @@ export async function resolveCodexModelEntitlements( ? [...options.credentials].filter(credential => !options.excludeAccountIds?.has(credential.accountId)) : (await Promise.all(allowedAccountIds.map(accountId => credentialSnapshot(accountId, options)))) .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null); + // The credential phase is the only part the native-main lease fences. The + // real snapshot releases it as soon as the main token settles; this boundary + // release keeps the guarantee when a seam snapshot never invokes it. + options.releaseNativeMainCredentialLease?.(); const results = await Promise.all(credentials.map(async credential => ({ credential, result: await modelsForCredential( diff --git a/src/codex/native-main-admission.ts b/src/codex/native-main-admission.ts index f5c7e809c3b..7083887b48d 100644 --- a/src/codex/native-main-admission.ts +++ b/src/codex/native-main-admission.ts @@ -3,7 +3,13 @@ import { tryAcquireNativeMainProfileClaim as tryAcquireLifecycleNativeMainProfileClaim, tryClaimNativeMainProfileForTurn as tryClaimLifecycleNativeMainProfileForTurn, } from "../server/lifecycle"; +import { + MAIN_CODEX_ACCOUNT_ID, + MainAccountTokenRefreshError, + MainAuthJsonChangedDuringRefreshError, +} from "./main-account"; import { isNativeMainTrafficBlocked } from "./native-profile-startup"; +import { NativeProfileError } from "./native-profile-types"; export interface NativeMainTurnClaimDeps { /** Test seams for the synchronous precheck/claim/postcheck transition. */ @@ -45,3 +51,80 @@ export function tryAcquireNativeMainProfileClaim(): AdmissionLease | null { claim.release(); return null; } + +export interface NativeMainCredentialAdmissionDeps { + /** Test seam for the synchronous admission precheck. */ + readonly acquireNativeMain?: () => AdmissionLease | null; +} + +const NO_EXCLUDED_ACCOUNT_IDS: ReadonlySet = new Set(); +const NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS: ReadonlySet = new Set([MAIN_CODEX_ACCOUNT_ID]); +const RELEASE_NOTHING = () => {}; + +/** + * Run credential-backed work inside the native-main lifecycle fence. + * + * The lease keeps startup recovery and profile drains from owning the physical + * credential while the operation reads it. Cross-process ownership of the file + * itself is already coordinated inside the refresh path's exclusive claim, so + * this fence deliberately does not take the shared claim: holding it across an + * operation that may refresh would ask for exclusive ownership against our own + * shared lock, and holding it across the upstream work that follows would + * stall an unrelated credential commit behind a network fetch. + * + * The fence covers only the credential read. The operation must invoke + * `releaseMainLease` as soon as the native-main credential settles — on both + * the success and the credential-error path — and before any upstream model + * listing, so a profile drain never waits on a network fetch while this turn + * is still counted. The wrapper releases on settle regardless, so an + * operation without a fenced credential phase may ignore the callback. + * + * When the gate refuses, or the credential cannot be read because another + * lifecycle owns it, the operation reruns with main excluded so independent + * Pool work is never suppressed by main's unavailability. + */ +export async function withNativeMainCredentialAdmission( + operation: ( + excludeAccountIds: ReadonlySet, + releaseMainLease?: () => void, + ) => Promise, + deps: NativeMainCredentialAdmissionDeps = {}, +): Promise { + const lease = (deps.acquireNativeMain ?? tryAcquireNativeMainProfileClaim)(); + if (!lease) return operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING); + let released = false; + const releaseMainLease = () => { + if (released) return; + released = true; + lease.release(); + }; + try { + return await operation(NO_EXCLUDED_ACCOUNT_IDS, releaseMainLease); + } catch (error) { + // The Pool-only retry never reads the native-main credential; release first + // so a profile drain is not kept waiting behind Pool network work. + releaseMainLease(); + if (!isNativeMainCredentialUnavailableError(error)) throw error; + return await operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING); + } finally { + releaseMainLease(); + } +} + +/** + * The credential-ownership failures that make main unavailable for one + * operation: a foreign exclusive holder or an unsupported claim filesystem + * (NATIVE_MAIN_CLAIM_BUSY / NATIVE_MAIN_CLAIM_UNAVAILABLE), a writer that moved + * auth.json mid-refresh, or a grant that no longer refreshes. Like a Pool + * credential failure, none of them may suppress independent Pool discovery. + * Any other NativeProfileError — MAIN_REQUESTS_ACTIVE, VAULT_INVALID, + * INTERNAL_ERROR — is not a credential-ownership failure and propagates. + */ +function isNativeMainCredentialUnavailableError(error: unknown): boolean { + if (error instanceof NativeProfileError) { + return error.code === "NATIVE_MAIN_CLAIM_BUSY" + || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE"; + } + return error instanceof MainAuthJsonChangedDuringRefreshError + || error instanceof MainAccountTokenRefreshError; +} diff --git a/src/codex/routing.ts b/src/codex/routing.ts index a6c06eed431..5a092186d32 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -28,6 +28,7 @@ import { type CodexUpstreamOutcomeMeta, } from "./routing/cooldown-math"; import { + carriesQuotaRefusal, codexPoolKeyForScope, codexQuotaScopeForModel, deleteAccountHealth, @@ -38,6 +39,7 @@ import { getCodexAccountCooldownUntil, getCodexAccountSoftAvoidUntil, getCodexQuotaHealthSnapshot, + hasUnrecoveredCodexQuotaRefusal, isCodexAccountSoftAvoided, isCodexQuotaAvoided, isHealthAccountAdmissible, @@ -93,6 +95,7 @@ import { getEligiblePoolAccounts, getPoolAccountPlanForSelection, hasCodexQuotaHeadroom, + hasCodexSharedStateQuotaHeadroom, isCodexAccountPlanExcluded, isCodexAccountSelectable, isHealthySharedCodexSelection, @@ -489,45 +492,6 @@ export function resolveCodexAccountForThread( return resolution.status === "selected" ? resolution.accountId : null; } -function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean { - return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402; -} - -/** - * Has this account refused a request on quota without serving one since? - * - * Thread affinity is a prompt-cache optimization and every rule around it is a preference: - * `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity` - * deliberately raises that bar further. A refusal is not a preference, and once the account has - * told THIS thread it cannot serve, the binding has nothing left to optimize. - * - * The distinction matters because the cooldown a 429 writes is deliberately short. A reset - * announcement is advisory — plan quota routinely frees up before the advertised instant — so - * {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that - * announcement describes is not capped, so an account whose burst window is spent looks - * selectable again long before it is. For an unbound request that is correct: going back to find - * out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit — - * the cooldown lapses, the account still scores lowest on the only window this proxy has a - * reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the - * identical 429. Cleared affinity does not help: the next request re-derives the same choice. - * From the Codex side that reads exactly as reported — a new session rotates normally while an - * existing one is locked to an exhausted account until the proxy is restarted, because a restart - * is the only thing that drops the binding and the stale health together. - * - * `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields} - * strips it from every recovery write, so it survives exactly until the account actually serves a - * request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes - * the account and the first success releases every thread this refused. - * - * Scope follows where the refusal was recorded. An account-wide throttle lands in - * `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native - * quota group, so a spent Spark window still cannot displace the same thread's Terra binding. - */ -function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean { - if (carriesQuotaRefusal(getAccountHealth(accountId))) return true; - return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope)); -} - function previewReusableAffinityAccount( entry: ThreadAffinityEntry | undefined, config: OcxConfig, @@ -952,7 +916,7 @@ export function resolveCodexAccountForThreadDetailed( // the account has already told this thread it cannot serve it. const quotaRefused = hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope); const healthyForSharedAffinity = selectableForSharedState - && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, entry.accountId, quotaScope, sharedSelectionOptions, now) && !quotaRefused && !failoverReady; if ( @@ -1150,7 +1114,7 @@ export function resolveCodexAccountForThreadDetailed( sharedSelectionOptions, ); const activeHealthyForSharedSelection = activeSelectableForSharedState - && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, active, quotaScope, sharedSelectionOptions, now) && !shouldFailover(config, active, now); if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) { const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions); diff --git a/src/codex/routing/health-store.ts b/src/codex/routing/health-store.ts index 9c0d922b970..57d4cc1ab30 100644 --- a/src/codex/routing/health-store.ts +++ b/src/codex/routing/health-store.ts @@ -350,6 +350,45 @@ export function deleteAccountHealth(accountId: string): void { upstreamHealth.delete(accountId); } +export function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean { + return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402; +} + +/** + * Has this account refused a request on quota without serving one since? + * + * Thread affinity is a prompt-cache optimization and every rule around it is a preference: + * `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity` + * deliberately raises that bar further. A refusal is not a preference, and once the account has + * told THIS thread it cannot serve, the binding has nothing left to optimize. + * + * The distinction matters because the cooldown a 429 writes is deliberately short. A reset + * announcement is advisory — plan quota routinely frees up before the advertised instant — so + * {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that + * announcement describes is not capped, so an account whose burst window is spent looks + * selectable again long before it is. For an unbound request that is correct: going back to find + * out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit — + * the cooldown lapses, the account still scores lowest on the only window this proxy has a + * reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the + * identical 429. Cleared affinity does not help: the next request re-derives the same choice. + * From the Codex side that reads exactly as reported — a new session rotates normally while an + * existing one is locked to an exhausted account until the proxy is restarted, because a restart + * is the only thing that drops the binding and the stale health together. + * + * `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields} + * strips it from every recovery write, so it survives exactly until the account actually serves a + * request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes + * the account and the first success releases every thread this refused. + * + * Scope follows where the refusal was recorded. An account-wide throttle lands in + * `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native + * quota group, so a spent Spark window still cannot displace the same thread's Terra binding. + */ +export function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean { + if (carriesQuotaRefusal(getAccountHealth(accountId))) return true; + return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope)); +} + export function listScopedHealthEntries(accountId: string): Array<[CodexQuotaScope, CodexUpstreamHealth]> { return [...(quotaScopedHealth.get(accountId) ?? [])]; } diff --git a/src/codex/routing/selection.ts b/src/codex/routing/selection.ts index 2720a5eaafc..9ac5538a000 100644 --- a/src/codex/routing/selection.ts +++ b/src/codex/routing/selection.ts @@ -22,6 +22,7 @@ import { dropSpentCredentialFailure, getAccountHealth, getCodexQuotaHealthSnapshot, + hasUnrecoveredCodexQuotaRefusal, isCodexAccountSoftAvoided, isCodexQuotaAvoided, isIndependentCodexQuotaScope, @@ -276,6 +277,40 @@ export function isCacheAffinityEnabled(config: OcxConfig): boolean { return config.pool?.cacheAffinity !== false; } +/** + * Whether quota may retire shared state while cache affinity is active. + * + * A threshold crossing is a hint that an account is getting busy, not evidence it cannot + * serve — the same bar {@link mayRebindAffinityForQuota} applies to a live binding. Shared + * state held across a model detour gets that exhaustion boundary for the same reason: the + * detour is request-scoped, so retiring the binding over a hint pays a cold prefix for + * nothing. New/unbound selection still reads {@link hasCodexQuotaHeadroom}; only + * preservation of an existing shared selection or thread binding qualifies here. Like the + * live-binding rule, the configured threshold plays no role once retention applies: a + * genuinely exhausted (>=100%) account releases even with threshold switching disabled, + * while the fallback above keeps a disabled threshold's "never drained on quota alone". + */ +export function hasCodexSharedStateQuotaHeadroom( + config: OcxConfig, + accountId: string, + quotaScope: CodexQuotaScope | undefined, + selectionOptions?: CodexAccountUsabilityOptions, + now: number = Date.now(), +): boolean { + if ( + !isCacheAffinityEnabled(config) + || accountPoolStrategyForScope(config, quotaScope) !== "quota" + ) { + return hasCodexQuotaHeadroom(config, accountId, selectionOptions, now); + } + const usage = computeCodexUsageScore( + getAccountQuota(accountId), + getPoolAccountPlanForSelection(config, accountId, selectionOptions), + now, + ); + return isUnknownUsage(usage) || usage < 100; +} + /** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */ export function pickResetFirstCodexAccount( config: OcxConfig, @@ -726,7 +761,8 @@ export function isHealthySharedCodexSelection( selectionOptions: CodexAccountUsabilityOptions | undefined, ): boolean { return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions) - && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, accountId, quotaScope, selectionOptions, now) + && !hasUnrecoveredCodexQuotaRefusal(accountId, quotaScope) && !shouldFailover(config, accountId, now); } diff --git a/src/providers/api-key-resolve.ts b/src/providers/api-key-resolve.ts new file mode 100644 index 00000000000..3b78418f98f --- /dev/null +++ b/src/providers/api-key-resolve.ts @@ -0,0 +1,133 @@ +/** + * Read-path for provider key material: env references, keychain references, or the literal + * value. + * + * Leaf module on purpose: reasoning-metadata.ts imports resolveProviderApiKey() here so a + * learned-refusal identity can hash the same credential the wire sends. key-store.ts needs + * the ../config barrel for the write path (saveConfigPreservingClaudeCode), and importing it + * from reasoning-metadata would close the cycle the reasoning-metadata header warns about. + * resolveEnvValue comes from ../config/proxy-env for the same reason. + * + * `config.json` keeps only a reference (`keychain:` for the active key, + * `keychain:/` for pool entries); the secret lives in the OS credential store + * under one service name. Reads are synchronous on purpose: `routedProviderConfig` and the + * quota/compaction/catalog callers are all sync, and `@napi-rs/keyring` ships a sync `Entry`. + * + * Policy: a reference that cannot be resolved fails closed (no key) and is warned once per + * account; nothing ever rewrites plaintext into config or its backups. + */ +import { createRequire } from "node:module"; +import { resolveEnvValue } from "../config/proxy-env"; +import type { OcxProviderConfig } from "../types"; + +export const KEYCHAIN_REFERENCE_PREFIX = "keychain:"; +export const PROVIDER_KEYCHAIN_SERVICE = "opencodex.provider-api-key.v1"; + +export interface ProviderKeychainEntry { + getPassword(): string | null; + setPassword(password: string): void; + deletePassword(): boolean; +} + +export type ProviderKeychainEntryFactory = (service: string, account: string) => ProviderKeychainEntry; + +const nodeRequire = createRequire(import.meta.url); + +function defaultEntryFactory(service: string, account: string): ProviderKeychainEntry { + const { Entry } = nodeRequire("@napi-rs/keyring") as { Entry: new (s: string, a: string) => ProviderKeychainEntry }; + return new Entry(service, account); +} + +let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory; +const resolvedCache = new Map(); +const warnedAccounts = new Set(); + +/** Test seam: swap the OS entry for an in-memory one and drop caches. */ +export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void { + entryFactory = factory ?? defaultEntryFactory; + resolvedCache.clear(); + warnedAccounts.clear(); +} + +/** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */ +export function invalidateResolvedProviderKeyCache(): void { + resolvedCache.clear(); + warnedAccounts.clear(); +} + +export function isKeychainReference(value: string | undefined): value is string { + return typeof value === "string" && value.startsWith(KEYCHAIN_REFERENCE_PREFIX) && value.length > KEYCHAIN_REFERENCE_PREFIX.length; +} + +export function keychainAccount(reference: string): string { + return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length); +} + +/** + * A reference belongs to `name` only when its account is that provider's own active account + * or one of its pool accounts. `storeProviderKeyInKeychain` writes exactly those two shapes, + * so anything else in a provider's config names another provider's secret. + */ +export function keychainReferenceBelongsToProvider(reference: string, name: string): boolean { + const account = keychainAccount(reference); + return account === name || account.startsWith(`${name}/`); +} + +/** Entry for `account` under the provider-key service; read and write paths share the factory. */ +export function providerKeychainEntry(account: string): ProviderKeychainEntry { + return entryFactory(PROVIDER_KEYCHAIN_SERVICE, account); +} + +function readKeychain(account: string): string | undefined { + const cached = resolvedCache.get(account); + if (cached !== undefined) return cached; + try { + const value = providerKeychainEntry(account).getPassword(); + if (typeof value === "string" && value.trim()) { + resolvedCache.set(account, value); + return value; + } + } catch { + // fall through to the single warning below + } + if (!warnedAccounts.has(account)) { + warnedAccounts.add(account); + console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`); + } + return undefined; +} + +/** + * Single resolver for provider key material: env references, keychain references, or the + * literal value. Every request-time read of `apiKey` goes through here. + */ +export function resolveProviderApiKey(value: string | undefined): string | undefined { + if (!value) return undefined; + if (isKeychainReference(value)) return readKeychain(keychainAccount(value)); + return resolveEnvValue(value); +} + +export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none"; + +export function providerKeyStoreKind(provider: Pick | undefined): ProviderKeyStoreKind { + const key = provider?.apiKey; + if (!key) return "none"; + if (isKeychainReference(key)) return "keychain"; + if (/^\$\{?\w+\}?$/.test(key)) return "env"; + return "file"; +} + +/** Probe the OS keychain with a throwaway account: write, read back, delete. */ +export function probeProviderKeychain(): { available: true } | { available: false; reason: string } { + const account = `probe-${process.pid}-${Date.now()}`; + try { + const entry = providerKeychainEntry(account); + entry.setPassword("ok"); + const back = entry.getPassword(); + try { entry.deletePassword(); } catch { /* best effort */ } + if (back !== "ok") return { available: false, reason: "keychain read-back did not match" }; + return { available: true }; + } catch (error) { + return { available: false, reason: error instanceof Error ? error.message : "keychain unavailable" }; + } +} diff --git a/src/providers/key-store.ts b/src/providers/key-store.ts index 614fd3372fa..428b018213f 100644 --- a/src/providers/key-store.ts +++ b/src/providers/key-store.ts @@ -1,7 +1,34 @@ -import { createRequire } from "node:module"; import { resolveEnvValue, saveConfigPreservingClaudeCode } from "../config"; import type { OcxConfig, OcxProviderConfig } from "../types"; import type { ProviderRegistryEntry } from "./registry"; +import { + KEYCHAIN_REFERENCE_PREFIX, + invalidateResolvedProviderKeyCache, + isKeychainReference, + keychainAccount, + keychainReferenceBelongsToProvider, + probeProviderKeychain, + providerKeychainEntry, +} from "./api-key-resolve"; + +// The read-path (reference predicates, the OS entry factory, resolveProviderApiKey, the probe +// and the kind classification) lives in ./api-key-resolve -- a leaf module that +// reasoning-metadata can also import without pulling the ../config barrel into its +// cycle-sensitive graph. Re-exported here so existing key-store consumers keep working. +export { + KEYCHAIN_REFERENCE_PREFIX, + PROVIDER_KEYCHAIN_SERVICE, + isKeychainReference, + probeProviderKeychain, + providerKeyStoreKind, + resolveProviderApiKey, + setProviderKeychainEntryFactoryForTests, +} from "./api-key-resolve"; +export type { + ProviderKeychainEntry, + ProviderKeychainEntryFactory, + ProviderKeyStoreKind, +} from "./api-key-resolve"; /** Shared with routing: a key-mode override is effective only while its key resolves. */ export function providerUsesKeyAuthOverride( @@ -27,109 +54,8 @@ export function providerUsesKeyAuthOverride( * (headless service, locked session) refuses rather than half-migrating. */ -export const KEYCHAIN_REFERENCE_PREFIX = "keychain:"; -export const PROVIDER_KEYCHAIN_SERVICE = "opencodex.provider-api-key.v1"; - -export interface ProviderKeychainEntry { - getPassword(): string | null; - setPassword(password: string): void; - deletePassword(): boolean; -} - -export type ProviderKeychainEntryFactory = (service: string, account: string) => ProviderKeychainEntry; - -const nodeRequire = createRequire(import.meta.url); - -function defaultEntryFactory(service: string, account: string): ProviderKeychainEntry { - const { Entry } = nodeRequire("@napi-rs/keyring") as { Entry: new (s: string, a: string) => ProviderKeychainEntry }; - return new Entry(service, account); -} - -let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory; -const resolvedCache = new Map(); -const warnedAccounts = new Set(); - -/** Test seam: swap the OS entry for an in-memory one and drop caches. */ -export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void { - entryFactory = factory ?? defaultEntryFactory; - resolvedCache.clear(); - warnedAccounts.clear(); -} - -export function isKeychainReference(value: string | undefined): value is string { - return typeof value === "string" && value.startsWith(KEYCHAIN_REFERENCE_PREFIX) && value.length > KEYCHAIN_REFERENCE_PREFIX.length; -} - -function keychainAccount(reference: string): string { - return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length); -} - -/** - * A reference belongs to `name` only when its account is that provider's own active account - * or one of its pool accounts. `storeProviderKeyInKeychain` writes exactly those two shapes, - * so anything else in a provider's config names another provider's secret. - */ -function keychainReferenceBelongsToProvider(reference: string, name: string): boolean { - const account = keychainAccount(reference); - return account === name || account.startsWith(`${name}/`); -} - -function readKeychain(account: string): string | undefined { - const cached = resolvedCache.get(account); - if (cached !== undefined) return cached; - try { - const value = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).getPassword(); - if (typeof value === "string" && value.trim()) { - resolvedCache.set(account, value); - return value; - } - } catch { - // fall through to the single warning below - } - if (!warnedAccounts.has(account)) { - warnedAccounts.add(account); - console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`); - } - return undefined; -} - -/** - * Single resolver for provider key material: env references, keychain references, or the - * literal value. Every request-time read of `apiKey` goes through here. - */ -export function resolveProviderApiKey(value: string | undefined): string | undefined { - if (!value) return undefined; - if (isKeychainReference(value)) return readKeychain(keychainAccount(value)); - return resolveEnvValue(value); -} - -export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none"; - -export function providerKeyStoreKind(provider: Pick | undefined): ProviderKeyStoreKind { - const key = provider?.apiKey; - if (!key) return "none"; - if (isKeychainReference(key)) return "keychain"; - if (/^\$\{?\w+\}?$/.test(key)) return "env"; - return "file"; -} - -/** Probe the OS keychain with a throwaway account: write, read back, delete. */ -export function probeProviderKeychain(): { available: true } | { available: false; reason: string } { - const account = `probe-${process.pid}-${Date.now()}`; - try { - const entry = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account); - entry.setPassword("ok"); - const back = entry.getPassword(); - try { entry.deletePassword(); } catch { /* best effort */ } - if (back !== "ok") return { available: false, reason: "keychain read-back did not match" }; - return { available: true }; - } catch (error) { - return { available: false, reason: error instanceof Error ? error.message : "keychain unavailable" }; - } -} - function writeVerified(account: string, secret: string): void { - const entry = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account); + const entry = providerKeychainEntry(account); entry.setPassword(secret); if (entry.getPassword() !== secret) throw new Error(`keychain read-back mismatch for ${account}`); } @@ -177,13 +103,12 @@ export function storeProviderKeyInKeychain(config: OcxConfig, name: string): { o } } catch (error) { for (const account of written) { - try { entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).deletePassword(); } catch { /* best effort */ } + try { providerKeychainEntry(account).deletePassword(); } catch { /* best effort */ } } return { ok: false, error: `OS keychain write failed: ${error instanceof Error ? error.message : "unknown"}`, status: 503 }; } for (const apply of planned) apply(); - resolvedCache.clear(); - warnedAccounts.clear(); + invalidateResolvedProviderKeyCache(); saveConfigPreservingClaudeCode(config); return { ok: true, moved: written.length }; } @@ -211,7 +136,7 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): const account = keychainAccount(ref); if (resolved.has(account)) continue; let value: string | null = null; - try { value = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).getPassword(); } catch { value = null; } + try { value = providerKeychainEntry(account).getPassword(); } catch { value = null; } if (!value) return { ok: false, error: `OS keychain has no readable secret for ${ref}; config left unchanged`, status: 503 }; resolved.set(account, value); } @@ -220,10 +145,9 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): } if (isKeychainReference(provider.apiKey)) provider.apiKey = resolved.get(keychainAccount(provider.apiKey))!; for (const account of resolved.keys()) { - try { entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).deletePassword(); } catch { /* best effort */ } + try { providerKeychainEntry(account).deletePassword(); } catch { /* best effort */ } } - resolvedCache.clear(); - warnedAccounts.clear(); + invalidateResolvedProviderKeyCache(); saveConfigPreservingClaudeCode(config); return { ok: true, restored: resolved.size }; } diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 25b312eced8..f22ef8363ea 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -18,6 +18,7 @@ * entitlement gap (muse-spark max needs an active Muse Code subscription) costs one rejected * request instead of failing every turn that selects that rung. */ +import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; // Leaf modules on purpose: this file is imported from reasoning-effort.ts, which combos/types.ts @@ -26,6 +27,7 @@ import { join } from "node:path"; import { atomicWriteFile } from "../config/atomic-write"; import { getConfigDir } from "../config/paths"; import type { OcxProviderConfig } from "../types"; +import { resolveProviderApiKey } from "./api-key-resolve"; const FILENAME = "reasoning-metadata-cache.json"; const SUPPORT_FILENAME = "reasoning-support-cache.json"; @@ -87,7 +89,7 @@ interface MetadataSnapshot { } interface SupportSnapshot { - version: 1; + version: 2; rows: Record; } @@ -159,11 +161,31 @@ function modelLadderValue( return undefined; } -/** Opaque row key: providerKey|modelId|effort. None of the three may contain a pipe. */ -const KEY_SEP = "|"; +/** + * Bind learned capability to the credential that supplied the evidence. A digest keeps the + * credential itself out of the persisted cache while remaining stable across restarts and key + * selection. Providers without key-auth identity may use metadata, but cannot teach the cache. + * + * The hash input is the resolved wire credential, not the configured expression: the catalog + * path carries the raw config string in apiKey (a keychain:/env reference stays unresolved + * there), while the request path carries the resolved secret in apiKey and the configured + * expression in _apiKeyAttempt.reference. The request path hashes apiKey exactly as routed -- + * the reference is only provenance, and re-resolving it at record time could read a credential + * rotated since the request was served. The catalog path resolves the configured expression, + * so both sides still bind learned refusals to the same wire credential, and a rotation behind + * a stable reference starts clean instead of inheriting the previous credential's refusals. + */ +function credentialIdentity(provider: OcxProviderConfig): string | undefined { + const resolved = provider._apiKeyAttempt?.reference !== undefined + ? provider.apiKey + : resolveProviderApiKey(provider.apiKey); + if (typeof resolved !== "string" || resolved.length === 0) return undefined; + return createHash("sha256").update(resolved).digest("hex"); +} -function supportKey(providerKey: string, modelId: string, effort: string): string { - return providerKey + KEY_SEP + modelId + KEY_SEP + effort; +/** JSON encoding avoids delimiter ambiguity in provider, model, and effort identifiers. */ +function supportKey(providerKey: string, credential: string, modelId: string, effort: string): string { + return JSON.stringify([providerKey, credential, modelId, effort]); } function loadSnapshot(): MetadataSnapshot | null { @@ -220,7 +242,9 @@ function loadSupport(): Map { } const rows = new Map(); const parsed = readJsonFile(SUPPORT_FILENAME); - if (parsed && parsed.version === 1 && parsed.rows && typeof parsed.rows === "object") { + // Version 1 rows had no credential identity and are deliberately invalidated: accepting them + // would preserve destination-wide refusals written by a lower-entitlement account. + if (parsed && parsed.version === 2 && parsed.rows && typeof parsed.rows === "object") { for (const [key, row] of Object.entries(parsed.rows)) { if (!row || typeof row.at !== "number") continue; if (nowMs - row.at > SUPPORT_TTL_MS) continue; @@ -273,8 +297,9 @@ export function metadataDeclaresType(provider: OcxProviderConfig, modelId: strin export function isReasoningEffortLearnedUnsupported(provider: OcxProviderConfig, modelId: string, effort: string): boolean { const key = metadataProviderKey(provider); - if (!key) return false; - return loadSupport().has(supportKey(key, modelId, effort)); + const credential = credentialIdentity(provider); + if (!key || !credential) return false; + return loadSupport().has(supportKey(key, credential, modelId, effort)); } /** @@ -291,10 +316,11 @@ export function dropLearnedUnsupportedReasoningEfforts( ): string[] { if (efforts.length === 0) return [...efforts]; const key = metadataProviderKey(provider); - if (!key) return [...efforts]; + const credential = credentialIdentity(provider); + if (!key || !credential) return [...efforts]; const support = loadSupport(); if (support.size === 0) return [...efforts]; - const kept = efforts.filter(effort => !support.has(supportKey(key, modelId, effort))); + const kept = efforts.filter(effort => !support.has(supportKey(key, credential, modelId, effort))); return kept.length === 0 ? [...efforts] : kept; } @@ -334,8 +360,9 @@ export function recordUnsupportedReasoningEffort( evidence?: string, ): boolean { const key = metadataProviderKey(provider); - if (!key || !effort) return false; - const rowKey = supportKey(key, modelId, effort); + const credential = credentialIdentity(provider); + if (!key || !credential || !effort) return false; + const rowKey = supportKey(key, credential, modelId, effort); const rows = loadSupport(); if (rows.has(rowKey)) return false; rows.set(rowKey, Date.now()); @@ -346,15 +373,14 @@ export function recordUnsupportedReasoningEffort( try { const out: SupportSnapshot["rows"] = {}; for (const [rowKey, at] of rows) { - const parts = rowKey.split(KEY_SEP); const evidenceText = supportEvidence.get(rowKey); out[rowKey] = { - effort: parts[2] ?? "", + effort: JSON.parse(rowKey)[3] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}), }; } - atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 1, rows: out }) + "\n"); + atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); } catch { // Best-effort persistence only. } @@ -371,11 +397,10 @@ export function flushReasoningSupportCache(): void { const rows = loadSupport(); const out: SupportSnapshot["rows"] = {}; for (const [rowKey, at] of rows) { - const parts = rowKey.split(KEY_SEP); const evidenceText = supportEvidence.get(rowKey); - out[rowKey] = { effort: parts[2] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}) }; + out[rowKey] = { effort: JSON.parse(rowKey)[3] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}) }; } - atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 1, rows: out }) + "\n"); + atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); } catch { // Best-effort persistence only. } diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts index d97c96db996..a29ce1888e8 100644 --- a/src/reasoning-effort.ts +++ b/src/reasoning-effort.ts @@ -12,6 +12,14 @@ export const CODEX_REASONING_LEVELS: { effort: string; description: string }[] = { effort: "ultra", description: "Maximum reasoning with automatic task delegation" }, ]; +/** + * One canonical rung. Catalog writers that hold a partially populated ladder derive their + * element type from this instead of restating `{ effort?: string }`: those restatements + * silently excluded `description`, so pushing a canonical rung into them is a type error + * that only surfaced once an unrelated import cycle through this module was broken. + */ +export type CodexReasoningLevel = (typeof CODEX_REASONING_LEVELS)[number]; + const CODEX_REASONING_ORDER = CODEX_REASONING_LEVELS.map(l => l.effort); const CODEX_REASONING_SET = new Set(CODEX_REASONING_ORDER); diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 6d3ed873339..4bd411a1ce6 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -49,8 +49,8 @@ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account"; import { availableAccountGatedNativeModels, codexModelEntitlementStateForAccount, - resolveCodexModelEntitlements, } from "../../codex/model-entitlements"; +import { resolveAdmittedCodexModelEntitlements } from "../../codex/model-entitlement-admission"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; import { registerCodexWebSocket, @@ -820,7 +820,13 @@ export function createServeOptions(ctx: ServeOptionsContext) { // Codex sends its own client_version on this request, and upstream filters the // entitlement roster by it. Passing it through is what stops an entitled account // being told it cannot use models a newer client can (#2886). - resolveCodexModelEntitlements(config, { clientVersion: url.searchParams.get("client_version") }), + // The request signal fences the credential phase too: a client that has already + // gone away must not keep a native-main token refresh alive, and its late result + // must not commit on behalf of a request that no longer exists. + resolveAdmittedCodexModelEntitlements(config, { + clientVersion: url.searchParams.get("client_version"), + signal: req.signal, + }), ]); } catch (error) { if (error instanceof CatalogGatherBusyError) { diff --git a/src/server/request-log.ts b/src/server/request-log.ts index f3d73ba8984..d7b4888ee58 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -70,6 +70,21 @@ import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/k import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; import { modelRecordValue } from "../reasoning-effort"; import type { RequestMetricsRecorder } from "./request-metrics"; +import type { + CacheDiagnosticDraft, + CacheDiagnosticFinalFacts, + PromptCacheKeySource, +} from "../usage/cache-diagnostic"; + +const CACHE_DIAGNOSTIC_HOOK = Symbol.for("opencodex.cache-diagnostic.v1"); +interface CacheDiagnosticHooks { + observeInbound(body: unknown, headers: Headers, source: PromptCacheKeySource): CacheDiagnosticDraft; + rebind(body: unknown, draft: CacheDiagnosticDraft | undefined): void; + finalize(facts: CacheDiagnosticFinalFacts): void; +} +function cacheDiagnosticHooks(): CacheDiagnosticHooks | undefined { + return (globalThis as Record)[CACHE_DIAGNOSTIC_HOOK]; +} export interface RequestLogContext { model: string; @@ -83,6 +98,8 @@ export interface RequestLogContext { * budget minted at ingress; a retry leg, a repair refetch and a combo child share it. */ logicalRequestId?: string; + /** Process-local privacy-bounded cache diagnostic; never persisted with request logs. */ + cacheDiagnosticDraft?: CacheDiagnosticDraft; /** * Internal live reference to this request's execution budget; omitted from RequestLogEntry and * JSONL. Read at final-log time so the row reports the budget's FINAL state rather than a @@ -209,6 +226,24 @@ export interface RequestLogContext { claudeCompatibility?: PersistedClaudeCompatibilityLog; } +export function observeCacheDiagnosticInbound( + logCtx: RequestLogContext, + body: unknown, + headers: Headers, + source: PromptCacheKeySource, +): void { + const draft = cacheDiagnosticHooks()?.observeInbound(body, headers, source); + if (draft) logCtx.cacheDiagnosticDraft = draft; +} + +/** Alias a rebuilt form of the request body to the request's diagnostic draft. */ +export function rebindCacheDiagnosticBody( + body: unknown, + draft: CacheDiagnosticDraft | undefined, +): void { + cacheDiagnosticHooks()?.rebind(body, draft); +} + export interface RequestLogEntry { requestId: string; /** The logical request this row belongs to (#4546); absent on rows written without a budget. */ @@ -1373,6 +1408,24 @@ export function addFinalRequestLog( wireParsed: logCtx.usageWireParsed === true, }); const logicalRequestId = logCtx.logicalRequestId ?? logCtx.executionBudget?.logicalRequestId; + const normalizedCacheValue = loggedUsage?.cacheReadInputTokens ?? loggedUsage?.cachedInputTokens; + cacheDiagnosticHooks()?.finalize({ + requestId, + ...(isLogicalRequestId(logicalRequestId) ? { logicalRequestId } : {}), + protocol: logCtx.inboundProtocol ?? "responses", + provider: logCtx.provider, + model: logCtx.model, + ...(isCodexUsageAccountLogLabel(logCtx.accountLogLabel) ? { accountLogLabel: logCtx.accountLogLabel } : {}), + ...(logCtx.affinity ? { affinityMove: logCtx.affinity } : {}), + ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}), + // loggedUsage carries the upstream cache counter by reference all the way from the + // adapter extraction for the native Responses route, so an undefined read here is a + // genuinely absent counter rather than a defaulted one. + ...(normalizedCacheValue !== undefined ? { rawCacheCounterValue: normalizedCacheValue } : {}), + ...(normalizedCacheValue !== undefined ? { normalizedCacheValue } : {}), + cacheProvenance, + ...(logCtx.cacheDiagnosticDraft ? { draft: logCtx.cacheDiagnosticDraft } : {}), + }); // Sanitize at the logging layer, not only at the one call site that populates this today. // The value originates in an upstream-supplied model id, so an unsanitized newline would // let a single field forge a record boundary in any line-oriented log viewer. Doing it here diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index a96a672ada1..867f816414d 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -52,7 +52,7 @@ import { parseRequest } from "../../responses/parser"; import { anthropicSessionKeyFromParts } from "../../oauth/anthropic-routing"; import { isTranslatorBudgetExceededError } from "../../lib/translator-budget"; import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../responses/turn-termination"; -import { requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log"; +import { observeCacheDiagnosticInbound, rebindCacheDiagnosticBody, requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log"; import type { RouteResult } from "../../router"; import { captureRouteStaticPolicy, @@ -157,6 +157,14 @@ export async function prepareResponsesRequest( } return decodeRequestErrorResponse(err, "responses"); } + observeCacheDiagnosticInbound( + logCtx, + body, + req.headers, + options.promptCacheKeyIsSharedCohort === true + ? "system-derived" + : options.promptCacheKeyIsSharedCohort === false ? "metadata-derived" : "caller", + ); if (!options.comboAttempt && !options.compactionRoutingOverride && inboundWire === "responses") { options.compactionRoutingOverride = applyCompactionRoutingOverride(body, req.headers, config, { endpoint: "responses", @@ -300,6 +308,10 @@ export async function prepareResponsesRequest( try { parsed = parseRequest(body); parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; + // The body may have been rebuilt since the inbound observation (previous-response + // expansion); alias the parsed raw body to the same draft so the outbound + // observation at the adapter seam still finds it. + rebindCacheDiagnosticBody(parsed._rawBody, logCtx.cacheDiagnosticDraft); // Captured before any parser mutates it, so both grammars see the client's id. const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config); if (fastRow) { diff --git a/src/usage/cache-diagnostic.ts b/src/usage/cache-diagnostic.ts new file mode 100644 index 00000000000..cd087f6db53 --- /dev/null +++ b/src/usage/cache-diagnostic.ts @@ -0,0 +1,305 @@ +/** Privacy-bounded, process-local cache diagnostics. Enable with OPENCODEX_CACHE_DEBUG=1. */ +import { createHmac, randomBytes } from "node:crypto"; +import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { CODEX_AFFINITY_DEBUG_SAFE_HEADERS } from "../codex/affinity-debug"; +import { getConfigDir } from "../config"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import type { CacheTelemetryProvenance } from "./log"; + +const CACHE_DEBUG_KEY = randomBytes(32); +const MAX_BLOCKS = 128; +const MAX_DRAFTS = 512; +export const CACHE_DEBUG_MAX_LINES = 200; +export const CACHE_DEBUG_KEEP_LINES = 100; + +export type PromptCacheKeySource = "caller" | "metadata-derived" | "system-derived" | "proxy-synthesized"; +export interface TaggedPresence { present: boolean; tag?: string; source?: PromptCacheKeySource } +export interface TaggedSequence { present: boolean; count: number; tags: string[]; truncated?: true } +export interface PrefixFingerprint { + instructions: TaggedSequence; + tools: TaggedSequence; + messages: TaggedSequence; +} +export interface CacheDiagnosticDraft { + promptCacheKey?: { inbound?: TaggedPresence; outbound?: TaggedPresence }; + session?: { inboundHeader?: TaggedPresence; outboundHeader?: TaggedPresence }; + prefix?: { inbound?: PrefixFingerprint; outbound?: PrefixFingerprint }; +} + +export interface CacheDiagnosticFinalFacts { + requestId: string; + logicalRequestId?: string; + protocol: "responses" | "chat" | "messages"; + provider: string; + model: string; + accountLogLabel?: string; + affinityMove?: string; + affinityReason?: string; + /** + * The cache counter exactly as the upstream usage object carried it, read before any + * client-wire defaulting. Undefined means the upstream object had no cache counter at + * all, which keeps a measured zero distinct from an absent-then-defaulted zero. + */ + rawCacheCounterValue?: number; + normalizedCacheValue?: number; + cacheProvenance: CacheTelemetryProvenance; + draft?: CacheDiagnosticDraft; +} + +const drafts = new Map(); +const bodyDrafts = new WeakMap(); + +export function isCacheDiagnosticEnabled(): boolean { + return process.env.OPENCODEX_CACHE_DEBUG === "1"; +} + +export function cacheDiagnosticPath(): string { + return join(getConfigDir(), "cache-debug.jsonl"); +} + +function tag(domain: string, value: string): string { + return createHmac("sha256", CACHE_DEBUG_KEY) + .update(domain).update("\0").update(value).digest("hex").slice(0, 12); +} + +export function tagCacheDiagnosticValue(domain: string, value: string): string { + return tag(`cache-debug:${domain}`, value); +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; +} + +function taggedPresence(value: unknown, domain: string, source?: PromptCacheKeySource): TaggedPresence { + if (typeof value !== "string" || value.length === 0) return { present: false }; + return { present: true, tag: tag(domain, value), ...(source ? { source } : {}) }; +} + +function sessionPresence(headers: Headers | HeadersInit): TaggedPresence { + const normalized = headers instanceof Headers ? headers : new Headers(headers); + const values = CODEX_AFFINITY_DEBUG_SAFE_HEADERS.flatMap(name => { + const value = normalized.get(name); + return value === null ? [] : [[name, value] as const]; + }); + return values.length === 0 + ? { present: false } + : { present: true, tag: tag("cache-debug:session-headers", canonicalJson(values)) }; +} + +function sequence(blocks: unknown[], domain: string): TaggedSequence { + const bounded = blocks.slice(0, MAX_BLOCKS); + return { + present: blocks.length > 0, + count: blocks.length, + tags: bounded.map(block => tag(domain, canonicalJson(block))), + ...(blocks.length > MAX_BLOCKS ? { truncated: true as const } : {}), + }; +} + +function requestBlocks(body: Record): { instructions: unknown[]; messages: unknown[] } { + const instructions = body.instructions; + // The spread is load-bearing: an array-valued instructions field must be copied, never + // aliased, because the pushes below would otherwise mutate the live request body that + // the adapter is about to serialize upstream. + const instructionRows = instructions === undefined || instructions === null + ? [] : Array.isArray(instructions) ? [...instructions] : [instructions]; + const input = Array.isArray(body.input) ? body.input : Array.isArray(body.messages) ? body.messages : []; + const messages: unknown[] = []; + for (const block of input) { + if (block && typeof block === "object" && !Array.isArray(block)) { + const row = block as Record; + if (row.role === "system" || row.role === "developer") { + const content = row.content; + instructionRows.push(...(Array.isArray(content) ? content : [content])); + continue; + } + } + messages.push(block); + } + return { instructions: instructionRows, messages }; +} + +export function prefixFingerprint(body: unknown): PrefixFingerprint { + const record = body && typeof body === "object" && !Array.isArray(body) + ? body as Record : {}; + const tools = Array.isArray(record.tools) ? record.tools : []; + const blocks = requestBlocks(record); + return { + instructions: sequence(blocks.instructions, "cache-debug:prefix:instructions"), + tools: sequence(tools, "cache-debug:prefix:tools"), + messages: sequence(blocks.messages, "cache-debug:prefix:messages"), + }; +} + +function firstDivergence(inbound: PrefixFingerprint, outbound: PrefixFingerprint): + { section: "instructions" | "tools" | "messages"; index: number } | undefined { + for (const section of ["instructions", "tools", "messages"] as const) { + const before = inbound[section].tags; + const after = outbound[section].tags; + const compared = Math.min(before.length, after.length); + for (let index = 0; index < compared; index += 1) { + if (before[index] !== after[index]) return { section, index }; + } + if (inbound[section].count !== outbound[section].count) return { section, index: compared }; + } + return undefined; +} + +export function observe(requestId: string, observation: Partial): CacheDiagnosticDraft { + const draft = drafts.get(requestId) ?? {}; + Object.assign(draft, observation); + drafts.delete(requestId); + drafts.set(requestId, draft); + while (drafts.size > MAX_DRAFTS) drafts.delete(drafts.keys().next().value!); + return draft; +} + +export function observeInbound( + body: unknown, + headers: Headers, + source: PromptCacheKeySource = "caller", +): CacheDiagnosticDraft { + if (!isCacheDiagnosticEnabled()) return {}; + try { + const record = body && typeof body === "object" && !Array.isArray(body) + ? body as Record : {}; + const draft: CacheDiagnosticDraft = { + promptCacheKey: { inbound: taggedPresence(record.prompt_cache_key, "cache-debug:prompt-cache-key", source) }, + session: { inboundHeader: sessionPresence(headers) }, + prefix: { inbound: prefixFingerprint(body) }, + }; + if (body && typeof body === "object") bodyDrafts.set(body, draft); + return draft; + } catch { + return {}; + } +} + +/** + * Alias a later form of the same request body (for example after previous-response + * expansion) to an existing draft, so the outbound observation at the adapter seam can + * find it. The inbound fingerprint intentionally stays the literal pre-expansion body. + */ +export function rebindCacheDiagnosticBodyAlias(body: unknown, draft: CacheDiagnosticDraft | undefined): void { + if (!isCacheDiagnosticEnabled() || !draft) return; + if (body && typeof body === "object") bodyDrafts.set(body, draft); +} + +export function observeOutbound( + inboundBody: unknown, + outboundBody: unknown, + headers: HeadersInit, + source: PromptCacheKeySource = "proxy-synthesized", +): void { + if (!isCacheDiagnosticEnabled()) return; + try { + if (!inboundBody || typeof inboundBody !== "object") return; + const draft = bodyDrafts.get(inboundBody); + if (!draft) return; + const record = outboundBody && typeof outboundBody === "object" && !Array.isArray(outboundBody) + ? outboundBody as Record : {}; + const outbound = taggedPresence(record.prompt_cache_key, "cache-debug:prompt-cache-key", source); + const inboundKey = draft.promptCacheKey?.inbound; + if (outbound.present && inboundKey && outbound.tag === inboundKey.tag) { + outbound.source = inboundKey.source; + } + (draft.promptCacheKey ??= {}).outbound = outbound; + (draft.session ??= {}).outboundHeader = sessionPresence(headers); + (draft.prefix ??= {}).outbound = prefixFingerprint(outboundBody); + } catch { + /* diagnostics must never affect request handling */ + } +} + +function ensureDir(): void { + const dir = getConfigDir(); + recordOwnedConfigPath(dir, cacheDiagnosticPath()); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + try { chmodSync(dir, 0o700); } catch { /* best-effort */ } +} + +function trimRollingFile(path: string): void { + const lines = readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean); + if (lines.length <= CACHE_DEBUG_MAX_LINES) return; + writeFileSync(path, `${lines.slice(-CACHE_DEBUG_KEEP_LINES).join("\n")}\n`, { encoding: "utf8", mode: 0o600 }); + try { chmodSync(path, 0o600); } catch { /* best-effort */ } +} + +export function appendFinalCacheDiagnostic(facts: CacheDiagnosticFinalFacts): void { + if (!isCacheDiagnosticEnabled()) return; + try { + const draft = facts.draft ? observe(facts.requestId, facts.draft) : drafts.get(facts.requestId) ?? {}; + drafts.delete(facts.requestId); + const inboundKey = draft.promptCacheKey?.inbound; + const outboundKey = draft.promptCacheKey?.outbound; + const inboundPrefix = draft.prefix?.inbound ?? prefixFingerprint(undefined); + const outboundPrefix = draft.prefix?.outbound ?? prefixFingerprint(undefined); + const record = { + version: 1 as const, + ts: Date.now(), + requestId: facts.requestId, + ...(facts.logicalRequestId ? { logicalRequestId: facts.logicalRequestId } : {}), + protocol: facts.protocol, + provider: facts.provider, + model: facts.model, + promptCacheKey: { + inbound: inboundKey ?? { present: false }, + outbound: outboundKey ?? { present: false }, + ...(inboundKey?.present && outboundKey?.present ? { equal: inboundKey.tag === outboundKey.tag } : {}), + }, + session: { + inboundHeader: draft.session?.inboundHeader ?? { present: false }, + outboundHeader: draft.session?.outboundHeader ?? { present: false }, + }, + prefix: { + inbound: inboundPrefix, + outbound: outboundPrefix, + ...(firstDivergence(inboundPrefix, outboundPrefix) + ? { firstDivergentBlock: firstDivergence(inboundPrefix, outboundPrefix) } + : {}), + }, + route: { + provider: facts.provider, + model: facts.model, + ...(facts.accountLogLabel + ? { accountTag: tag("cache-debug:account-log-label", facts.accountLogLabel) } + : {}), + ...(facts.affinityMove ? { affinityMove: facts.affinityMove } : {}), + ...(facts.affinityReason ? { affinityReason: facts.affinityReason } : {}), + }, + cache: { + rawUpstream: facts.rawCacheCounterValue !== undefined + ? { present: true, value: facts.rawCacheCounterValue } + : { present: false }, + normalized: { + present: facts.normalizedCacheValue !== undefined, + ...(facts.normalizedCacheValue !== undefined ? { value: facts.normalizedCacheValue } : {}), + provenance: facts.cacheProvenance, + }, + }, + }; + ensureDir(); + const path = cacheDiagnosticPath(); + appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 }); + try { chmodSync(path, 0o600); } catch { /* best-effort */ } + if (existsSync(path)) trimRollingFile(path); + } catch { + /* diagnostics must never affect request handling */ + } +} + +const CACHE_DIAGNOSTIC_HOOK = Symbol.for("opencodex.cache-diagnostic.v1"); +interface CacheDiagnosticHooks { + observeInbound(body: unknown, headers: Headers, source: PromptCacheKeySource): CacheDiagnosticDraft; + rebind(body: unknown, draft: CacheDiagnosticDraft | undefined): void; + finalize(facts: CacheDiagnosticFinalFacts): void; +} +(globalThis as Record)[CACHE_DIAGNOSTIC_HOOK] = { + observeInbound, + rebind: rebindCacheDiagnosticBodyAlias, + finalize: appendFinalCacheDiagnostic, +}; diff --git a/structure/catalog.md b/structure/catalog.md index e74edcb8adb..7b035ee99fc 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -166,6 +166,10 @@ Provider live-model lists are cached with a configured TTL (`src/codex/model-cac deleting, or editing a provider's shape clears that per-provider cache; a disabled-only change deliberately does not, because a disabled provider is already excluded from the catalog gather instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. +Entitlement-specific rosters (Qoder, Devin, Cursor) additionally bind their cache entry to an +irreversible credential fingerprint: a credential switch observes neither the fresh nor the stale +roster recorded under the previous credential, and a failed discovery's cooldown neither supplies +the previous credential's stale roster nor suppresses the next credential's first discovery. A Devin live row spreads its measured `inputModalities` before `catalogHintsFromProviderConfig`, so exact `modelCapabilities` declarations, the legacy diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index d179abaa328..218df3838d5 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -638,6 +638,16 @@ once it exceeds 200) with the upstream content-type, body kind (`sse / json / ot body sample, and the extracted usage. Off by default; the hot path is guarded so production stays untouched. +For diagnosing cache-read instability without capturing content, set `OPENCODEX_CACHE_DEBUG=1` +before start. `src/usage/cache-diagnostic.ts` then writes one record per finalized request to +`~/.opencodex/cache-debug.jsonl` (same `0o600` file, same 200-to-100 rolling bound) holding only +presence booleans, counts, closed enums, the raw upstream cache counter before defaulting, and +process-local HMAC equality tags for the prompt-cache key, allowlisted session headers, the account +log label, and ordered instruction/tool/message blocks (capped at 128 per section, first divergent +section/index only). The signing key is created at process start and never persisted, so tags +compare values within one proxy process and never become a durable correlation key; no prompt +text, tool name, raw identifier, or header value is recorded. Off by default. + ## Z.ai quota destination ownership `src/providers/quota/vendor-probes-key.ts` uses one exact normalized-base mapping for both Z.ai quota diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index c9479b1b051..0fde5683acd 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -387,8 +387,11 @@ has headroom, auth resolution validates the caller bearer's own gated-model rost request-owned credential before stored-Pool selection. The credential never enters Pool persistence, affinity, entitlement cache, or health state, and this decision never reads the physical main credential. If the caller lacks the requested model, a stored-account model detour may serve the request without -clearing the healthy shared main pin. A paused or quota-drained main skips this exception and follows the -ordinary Pool promotion path. +clearing the healthy shared main pin. With quota-strategy cache affinity, the same detour preserves an +ordinary added-account binding and shared selection beyond the proactive-switch threshold until genuine +exhaustion; pause, cooldown, reauthentication, quota refusal, and failover evidence still retire shared +state normally. A paused or quota-drained main skips the request-owned credential exception and follows +the ordinary Pool promotion path. > Decision record: [ADR-0086](../decisions/ADR-0086-public-provider-contract.md) diff --git a/tests/codex-integration/codex-main-account-refresh.test.ts b/tests/codex-integration/codex-main-account-refresh.test.ts index d1ae3807955..9541891ba58 100644 --- a/tests/codex-integration/codex-main-account-refresh.test.ts +++ b/tests/codex-integration/codex-main-account-refresh.test.ts @@ -328,4 +328,28 @@ describe("publication never overwrites an external Codex writer (#2999)", () => expect(token?.accessToken).toBe("ocx-staged-access"); expect(readFileSync(authPath, "utf8")).toContain("ocx-staged-access"); }); + + test("a caller cancelled during the refresh does not commit the late result", async () => { + // The refresh seam resolves after the caller aborted (a client that disconnected + // mid-/v1/models). The late result must never reach auth.json on behalf of a + // request that no longer exists: the publication is fenced before the rename, + // the file keeps its prior bytes, and the credential mutation epoch does not + // advance for a commit that never happened. + const authPath = join(home, "auth.json"); + seedExpired(authPath); + const before = readFileSync(authPath, "utf8"); + const controller = new AbortController(); + const epochBefore = codexCredentialMutationEpoch(); + + await expect(getValidMainAccountToken({ + signal: controller.signal, + refreshToken: async () => { + controller.abort(new Error("client disconnected")); + return refreshOk(); + }, + })).rejects.toThrow("client disconnected"); + + expect(readFileSync(authPath, "utf8")).toBe(before); + expect(codexCredentialMutationEpoch()).toBe(epochBefore); + }); }); diff --git a/tests/codex-integration/codex-model-entitlement-admission.test.ts b/tests/codex-integration/codex-model-entitlement-admission.test.ts new file mode 100644 index 00000000000..aa145e6ce07 --- /dev/null +++ b/tests/codex-integration/codex-model-entitlement-admission.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + MAIN_CODEX_ACCOUNT_ID, + MainAccountTokenRefreshError, + MainAuthJsonChangedDuringRefreshError, +} from "../../src/codex/main-account"; +import { resolveAdmittedCodexModelEntitlements } from "../../src/codex/model-entitlement-admission"; +import { + ensureCodexEntitlementFreshness, + resetCodexModelEntitlementCacheForTests, + type CodexModelEntitlementResolveOptions, +} from "../../src/codex/model-entitlements"; +import { NativeProfileError } from "../../src/codex/native-profile-types"; +import { installIsolatedCodexHome } from "../helpers/isolated-codex-home"; + +const emptySnapshot = { + modelsByAccount: new Map>(), + clientVersionByAccount: new Map(), + confirmedAccountIds: new Set(), + credentialIdentities: new Map(), +}; + +describe("Codex model entitlement admission", () => { + test("excludes native main before credential discovery when lifecycle admission is blocked", async () => { + let received: CodexModelEntitlementResolveOptions | undefined; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => null, + resolve: async (_config, options) => { + received = options; + return emptySnapshot; + }, + }); + + expect(received?.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + }); + + test("holds the lifecycle lease through credential discovery", async () => { + const events: string[] = []; + let released = false; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => { + released = true; + events.push("lifecycle-release"); + } }), + resolve: async () => { + expect(released).toBe(false); + events.push("credential-discovery"); + return emptySnapshot; + }, + }); + + expect(events).toEqual([ + "credential-discovery", + "lifecycle-release", + ]); + }); + + test("falls back to Pool-only discovery when the credential claim is refused", async () => { + const exclusions: boolean[] = []; + let calls = 0; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => undefined }), + resolve: async (_config, options) => { + calls += 1; + if (calls === 1) { + throw new NativeProfileError("NATIVE_MAIN_CLAIM_BUSY", "busy", 503, true); + } + exclusions.push(options.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID) === true); + return emptySnapshot; + }, + }); + + expect(exclusions).toEqual([true]); + }); + + test("releases the lifecycle lease before the Pool-only retry", async () => { + let released = false; + const releasedAtRetry: boolean[] = []; + let calls = 0; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => { + released = true; + } }), + resolve: async (_config, options) => { + calls += 1; + if (calls === 1) { + throw new NativeProfileError("NATIVE_MAIN_CLAIM_BUSY", "busy", 503, true); + } + releasedAtRetry.push(released); + return emptySnapshot; + }, + }); + + expect(releasedAtRetry).toEqual([true]); + }); + + test("lets the credential phase release the lease before upstream work", async () => { + const events: string[] = []; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => { + events.push("lifecycle-release"); + } }), + resolve: async (_config, options) => { + events.push("credential-discovery"); + options.releaseNativeMainCredentialLease?.(); + events.push("upstream-request"); + return emptySnapshot; + }, + }); + + expect(events).toEqual([ + "credential-discovery", + "lifecycle-release", + "upstream-request", + ]); + }); + + test("a foreign credential owner or a failed grant still resolves the Pool", async () => { + for (const makeError of [ + () => new MainAuthJsonChangedDuringRefreshError(), + () => new MainAccountTokenRefreshError("reauth"), + () => new NativeProfileError("NATIVE_MAIN_CLAIM_BUSY", "busy", 503, true), + () => new NativeProfileError("NATIVE_MAIN_CLAIM_UNAVAILABLE", "unavailable", 503, true), + ]) { + const exclusions: boolean[] = []; + let calls = 0; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => undefined }), + resolve: async (_config, options) => { + calls += 1; + if (calls === 1) throw makeError(); + exclusions.push(options.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID) === true); + return emptySnapshot; + }, + }); + + expect(exclusions).toEqual([true]); + } + }); + + test("propagates failures that are not credential-ownership errors", async () => { + const failure = new Error("upstream exploded"); + + await expect(resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => undefined }), + resolve: async () => { + throw failure; + }, + })).rejects.toBe(failure); + }); + + test("propagates native profile errors that are not claim-ownership failures", async () => { + for (const code of ["INTERNAL_ERROR", "VAULT_INVALID", "MAIN_REQUESTS_ACTIVE"] as const) { + let calls = 0; + const failure = new NativeProfileError(code, "not a credential-ownership failure"); + + await expect(resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, {}, { + acquireNativeMain: () => ({ release: () => undefined }), + resolve: async () => { + calls += 1; + throw failure; + }, + })).rejects.toBe(failure); + expect(calls).toBe(1); + } + }); + + test("skips the fences for a caller-supplied roster or an already-excluded main", async () => { + let leaseAttempts = 0; + const seen: CodexModelEntitlementResolveOptions[] = []; + const deps = { + acquireNativeMain: () => { + leaseAttempts += 1; + return null; + }, + resolve: async (_config: unknown, options: CodexModelEntitlementResolveOptions) => { + seen.push(options); + return emptySnapshot; + }, + }; + + await resolveAdmittedCodexModelEntitlements({ codexAccounts: [] }, { credentials: [] }, deps); + await resolveAdmittedCodexModelEntitlements( + { codexAccounts: [] }, + { excludeAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID]) }, + deps, + ); + + expect(leaseAttempts).toBe(0); + expect(seen[0]?.credentials).toEqual([]); + expect(seen[1]?.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + }); +}); + +describe("entitlement freshness admission", () => { + let home: ReturnType; + + beforeEach(() => { + home = installIsolatedCodexHome("ocx-entitlement-admission-"); + }); + + afterEach(() => { + home.restore(); + resetCodexModelEntitlementCacheForTests(); + }); + + test("a denied native-main admission drops main from the refresh workset", async () => { + const snapshots: string[] = []; + + await ensureCodexEntitlementFreshness( + { codexAccounts: [{ id: "pool-fenced", email: "pool-fenced@example.test", isMain: false }] }, + { + clientVersion: "0.146.0", + waitMs: 1_000, + credentialSnapshot: async accountId => { + snapshots.push(accountId); + return null; + }, + nativeMainCredentialAdmission: async operation => operation(new Set([MAIN_CODEX_ACCOUNT_ID])), + }, + ); + + expect(snapshots).toContain("pool-fenced"); + expect(snapshots).not.toContain(MAIN_CODEX_ACCOUNT_ID); + }); + + test("an admitted freshness refresh snapshots main inside the claim", async () => { + const events: string[] = []; + + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { + clientVersion: "0.146.0", + waitMs: 1_000, + credentialSnapshot: async accountId => { + events.push(`snapshot:${accountId}`); + return null; + }, + nativeMainCredentialAdmission: async operation => { + events.push("admission-enter"); + const result = await operation(new Set()); + events.push("admission-exit"); + return result; + }, + }); + + const enteredAt = events.indexOf("admission-enter"); + const mainSnapshotAt = events.indexOf(`snapshot:${MAIN_CODEX_ACCOUNT_ID}`); + const exitedAt = events.indexOf("admission-exit"); + expect(enteredAt).toBeGreaterThanOrEqual(0); + expect(mainSnapshotAt).toBeGreaterThan(enteredAt); + expect(exitedAt).toBeGreaterThan(mainSnapshotAt); + }); + + test("releases the native-main lease before roster fetches", async () => { + const events: string[] = []; + + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { + clientVersion: "0.146.0", + waitMs: 1_000, + credentialSnapshot: async accountId => ({ + accountId, + accessToken: "token", + chatgptAccountId: "acct", + credentialIdentity: `test:${accountId}`, + }), + fetcher: async () => { + events.push("roster-fetch"); + return new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 }); + }, + nativeMainCredentialAdmission: async operation => operation(new Set(), () => { + events.push("lease-release"); + }), + }); + + const releasedAt = events.indexOf("lease-release"); + const fetchedAt = events.indexOf("roster-fetch"); + expect(releasedAt).toBeGreaterThanOrEqual(0); + expect(fetchedAt).toBeGreaterThan(releasedAt); + }); + + test("forwards the caller signal into entitlement resolution", async () => { + // A cancelled data-plane request (/v1/models) must fence its own credential + // refresh: the signal has to reach resolveCodexModelEntitlements, which hands + // it to the native-main token refresh and the roster phase. + const controller = new AbortController(); + let received: CodexModelEntitlementResolveOptions | undefined; + + await resolveAdmittedCodexModelEntitlements( + { codexAccounts: [] }, + { clientVersion: null, signal: controller.signal, credentials: [] }, + { + resolve: async (_config, options) => { + received = options; + return emptySnapshot; + }, + }, + ); + + expect(received?.signal).toBe(controller.signal); + }); +}); diff --git a/tests/codex-integration/codex-routing-cache-affinity-detour.test.ts b/tests/codex-integration/codex-routing-cache-affinity-detour.test.ts new file mode 100644 index 00000000000..ccf1fa38f9f --- /dev/null +++ b/tests/codex-integration/codex-routing-cache-affinity-detour.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearCodexUpstreamHealth, + clearThreadAccountMap, + getEffectiveActiveCodexAccountId, + resolveCodexAccountForThread, + resolveCodexAccountForThreadDetailed, +} from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { + clearAccountNeedsReauth, + clearAccountQuota, + updateAccountQuota, +} from "../../src/codex/auth-api"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; + +// These cases moved out of codex-routing.test.ts, which sits at its file-size +// cap. They cover the cache-affinity boundary between a proactive quota-switch +// threshold crossing (a hint) and genuine 100% exhaustion (evidence) for a +// shared binding during a model-scoped detour. + +let TEST_DIR = ""; +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + +function installRoutingScratchHome(): void { + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-routing-")); + // Routing cases exercise account state, not the operating system ACL implementation. + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.CODEX_HOME = TEST_DIR; +} + +async function removeRoutingScratchHome(): Promise { + const ownedDirectory = TEST_DIR; + try { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + } finally { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (ownedDirectory) removeTreeWithRetry(ownedDirectory); + } +} + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + ], + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + upstreamFailoverThreshold: 3, + ...overrides, + } as OcxConfig; +} + +function saveTestCredential(id: string): void { + saveCodexAccountCredential(id, { + accessToken: "access-" + id, + refreshToken: "refresh-" + id, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-" + id, + }); +} + +describe("cache affinity across model detours", () => { + beforeEach(() => { + installRoutingScratchHome(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearPoolRotationState(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + saveTestCredential("a"); + saveTestCredential("b"); + }); + + afterEach(async () => { + try { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + } finally { + await removeRoutingScratchHome(); + } + }); + + /** \`a\` is ordered above \`b\`; the persisted operator selection is the lower tier. */ + function orderedConfig(overrides: Partial = {}): OcxConfig { + return makeConfig({ + activeCodexAccountId: "b", + codexAccountPriorities: { a: 1 }, + ...overrides, + } as Partial); + } + + test("cache affinity preserves an over-threshold shared binding across a model detour", () => { + const config = orderedConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + activeCodexAccountPinned: "a", + autoSwitchThreshold: 80, + pool: { cacheAffinity: true }, + }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread("cache-affine-model-detour", config, now, "shared")).toBe("a"); + updateAccountQuota("a", 90); + expect(resolveCodexAccountForThreadDetailed( + "cache-affine-model-detour", + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["b"]) }, + )).toMatchObject({ status: "selected", accountId: "b" }); + + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread("cache-affine-model-detour", config, now + 2, "shared")).toBe("a"); + }); + + test("cache affinity releases a fully exhausted shared binding across a model detour", () => { + const config = orderedConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + activeCodexAccountPinned: "a", + autoSwitchThreshold: 80, + pool: { cacheAffinity: true }, + }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread("cache-affine-exhausted-detour", config, now, "shared")).toBe("a"); + updateAccountQuota("a", 100); + expect(resolveCodexAccountForThreadDetailed( + "cache-affine-exhausted-detour", + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["b"]) }, + )).toMatchObject({ status: "selected", accountId: "b" }); + + // Genuine exhaustion is the live-binding bar: the shared cursor follows the account + // that actually served instead of staying parked on the drained one. + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread("cache-affine-exhausted-detour", config, now + 2, "shared")).toBe("b"); + }); + + test("cache affinity releases an exhausted shared binding even with quota switching disabled", () => { + const config = orderedConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + activeCodexAccountPinned: "a", + autoSwitchThreshold: 0, + pool: { cacheAffinity: true }, + }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread("cache-affine-disabled-detour", config, now, "shared")).toBe("a"); + updateAccountQuota("a", 100); + expect(resolveCodexAccountForThreadDetailed( + "cache-affine-disabled-detour", + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["b"]) }, + )).toMatchObject({ status: "selected", accountId: "b" }); + + // Genuine exhaustion drops the binding even when threshold switching is disabled -- + // the same >=100% boundary a live binding gets -- and the shared selection follows + // the account that actually served. + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread("cache-affine-disabled-detour", config, now + 2, "shared")).toBe("b"); + }); +}); diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index b769a74c707..7d0d1b689b2 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -3174,7 +3174,7 @@ describe("codex account selection order", () => { activeCodexAccountPinned: "b", }); updateAccountQuota("a", 10); - updateAccountQuota("b", 90); + updateAccountQuota("b", 100); expect(resolveCodexAccountForThreadDetailed( null, @@ -3218,7 +3218,7 @@ describe("codex account selection order", () => { activeCodexAccountPinned: "b", }); updateAccountQuota("a", 10); - updateAccountQuota("b", 90); + updateAccountQuota("b", 100); expect(resolveCodexAccountForThreadDetailed( null, diff --git a/tests/codex-integration/reasoning-metadata.test.ts b/tests/codex-integration/reasoning-metadata.test.ts index 02e8d16c5ed..5c702cd71e1 100644 --- a/tests/codex-integration/reasoning-metadata.test.ts +++ b/tests/codex-integration/reasoning-metadata.test.ts @@ -16,6 +16,7 @@ import type { OcxProviderConfig } from "../../src/types"; const ZEN_GO: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", + apiKey: "test-zen-go-key", } as OcxProviderConfig; const MUSE_SPARK = "muse-spark-1.3-contributor"; @@ -63,8 +64,8 @@ function metadataFileV2(providers: Record, apis: Record): Record { - return { "reasoning-support-cache.json": JSON.stringify({ version: 1, rows }) }; +function supportFile(rows: Record, version = 2): Record { + return { "reasoning-support-cache.json": JSON.stringify({ version, rows }) }; } afterEach(() => { @@ -130,25 +131,136 @@ describe("models.dev reasoning metadata", () => { }); describe("learned rung refusals", () => { - const refused = () => ({ - ["opencode-go|" + DEEPSEEK_FLASH + "|max"]: { effort: "max", at: Date.now() }, - }); - test("drops a refused rung from a metadata-derived ladder", async () => { - const { effort } = await load({ + const { effort, metadata } = await load({ ...metadataFile({ "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } } }), - ...supportFile(refused()), }); + expect(metadata.recordUnsupportedReasoningEffort(ZEN_GO, DEEPSEEK_FLASH, "max")).toBe(true); expect(effort.configuredReasoningEfforts(ZEN_GO, DEEPSEEK_FLASH)).toEqual(["low", "high"]); expect(effort.mapReasoningEffort(ZEN_GO, DEEPSEEK_FLASH, "max")).toBe("high"); }); test("drops a refused rung from a ladder pinned in the registry too", async () => { - const { effort } = await load(supportFile(refused())); + const { effort, metadata } = await load(); + expect(metadata.recordUnsupportedReasoningEffort(ZEN_GO, DEEPSEEK_FLASH, "max")).toBe(true); const pinned = { ...ZEN_GO, modelReasoningEfforts: { [DEEPSEEK_FLASH]: ["low", "high", "max"] } } as OcxProviderConfig; expect(effort.configuredReasoningEfforts(pinned, DEEPSEEK_FLASH)).toEqual(["low", "high"]); }); + test("keeps learned refusals isolated between credentials at the same destination", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + const lowEntitlement = { ...ZEN_GO, apiKey: "low-entitlement-key" } as OcxProviderConfig; + const highEntitlement = { ...ZEN_GO, apiKey: "high-entitlement-key" } as OcxProviderConfig; + expect(metadata.recordUnsupportedReasoningEffort(lowEntitlement, DEEPSEEK_FLASH, "max")).toBe(true); + expect(effort.configuredReasoningEfforts(lowEntitlement, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + expect(effort.configuredReasoningEfforts(highEntitlement, DEEPSEEK_FLASH)).toEqual(["low", "high", "max"]); + }); + + // The catalog path carries the configured expression in apiKey; the request path carries the + // resolved secret in apiKey and the configured expression in _apiKeyAttempt.reference. Both + // must hash the same wire credential, or a refusal learned at request time never clamps the + // advertised ladder for env/keychain users. + test("a refusal learned under the resolved request key applies to the catalog's env reference", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + process.env["OCX_REASONING_METADATA_TEST_KEY"] = "resolved-env-secret"; + try { + const catalogSide = { ...ZEN_GO, apiKey: "${OCX_REASONING_METADATA_TEST_KEY}" } as OcxProviderConfig; + const requestSide = { + ...ZEN_GO, + apiKey: "resolved-env-secret", + _apiKeyAttempt: { reference: "${OCX_REASONING_METADATA_TEST_KEY}" }, + } as OcxProviderConfig; + expect(metadata.recordUnsupportedReasoningEffort(requestSide, DEEPSEEK_FLASH, "max")).toBe(true); + expect(effort.configuredReasoningEfforts(catalogSide, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + } finally { + delete process.env["OCX_REASONING_METADATA_TEST_KEY"]; + } + }); + + test("a refusal learned under the catalog's env reference applies to the resolved request key", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + process.env["OCX_REASONING_METADATA_TEST_KEY"] = "resolved-env-secret"; + try { + const catalogSide = { ...ZEN_GO, apiKey: "${OCX_REASONING_METADATA_TEST_KEY}" } as OcxProviderConfig; + const requestSide = { + ...ZEN_GO, + apiKey: "resolved-env-secret", + _apiKeyAttempt: { reference: "${OCX_REASONING_METADATA_TEST_KEY}" }, + } as OcxProviderConfig; + expect(metadata.recordUnsupportedReasoningEffort(catalogSide, DEEPSEEK_FLASH, "max")).toBe(true); + expect(effort.configuredReasoningEfforts(requestSide, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + } finally { + delete process.env["OCX_REASONING_METADATA_TEST_KEY"]; + } + }); + + test("a keychain-referenced key scopes refusals to the resolved secret on both paths", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + const keyStore = await import("../../src/providers/api-key-resolve"); + const store = new Map(); + store.set("opencodex.provider-api-key.v1 zen-go", "resolved-keychain-secret"); + keyStore.setProviderKeychainEntryFactoryForTests((service, account) => ({ + getPassword: () => store.get(service + " " + account) ?? null, + setPassword: (password) => { store.set(service + " " + account, password); }, + deletePassword: () => store.delete(service + " " + account), + })); + try { + const catalogSide = { ...ZEN_GO, apiKey: "keychain:zen-go" } as OcxProviderConfig; + const requestSide = { + ...ZEN_GO, + apiKey: "resolved-keychain-secret", + _apiKeyAttempt: { reference: "keychain:zen-go" }, + } as OcxProviderConfig; + expect(metadata.recordUnsupportedReasoningEffort(requestSide, DEEPSEEK_FLASH, "max")).toBe(true); + expect(effort.configuredReasoningEfforts(catalogSide, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + } finally { + keyStore.setProviderKeychainEntryFactoryForTests(null); + } + }); + + // The request path must hash the credential that served the request, not a live re-read of + // the reference: a rotation between routing and refusal recording would otherwise bind the + // learned refusal to the rotated credential and leave the refused one unclamped. + test("a refusal learned at request time stays bound to the serving credential after rotation", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + process.env["OCX_REASONING_METADATA_TEST_KEY"] = "serving-secret"; + const requestSide = { + ...ZEN_GO, + apiKey: "serving-secret", + _apiKeyAttempt: { reference: "${OCX_REASONING_METADATA_TEST_KEY}" }, + } as OcxProviderConfig; + // Rotate behind the stable reference after routing but before the refusal is recorded. + process.env["OCX_REASONING_METADATA_TEST_KEY"] = "rotated-secret"; + try { + expect(metadata.recordUnsupportedReasoningEffort(requestSide, DEEPSEEK_FLASH, "max")).toBe(true); + const served = { ...ZEN_GO, apiKey: "serving-secret" } as OcxProviderConfig; + const rotated = { ...ZEN_GO, apiKey: "rotated-secret" } as OcxProviderConfig; + expect(effort.configuredReasoningEfforts(served, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + expect(effort.configuredReasoningEfforts(rotated, DEEPSEEK_FLASH)).toEqual(["low", "high", "max"]); + } finally { + delete process.env["OCX_REASONING_METADATA_TEST_KEY"]; + } + }); + + test("ignores legacy destination-wide support rows", async () => { + const legacyRows = { ["opencode-go|" + DEEPSEEK_FLASH + "|max"]: { effort: "max", at: Date.now() } }; + const { effort } = await load({ + ...metadataFile({ "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } } }), + ...supportFile(legacyRows, 1), + }); + expect(effort.configuredReasoningEfforts(ZEN_GO, DEEPSEEK_FLASH)).toEqual(["low", "high", "max"]); + }); + test("records the refusal and plans the next lower published rung once", async () => { const { metadata } = await load({ ...metadataFile({ "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } } }), diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 646d7e75263..32ab9d258b1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -127,6 +127,7 @@ "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", "bun-stream-caps.test.ts": "lib", + "cache-diagnostic.test.ts": "usage", "cancel-body-on-abort.test.ts": "server", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", "catalog-cursor-search.test.ts": "codex-integration", @@ -353,6 +354,8 @@ "codex-management-convergence.test.ts": "codex-integration", "codex-metadata-integrity.test.ts": "codex-integration", "codex-model-entitlements.test.ts": "codex-integration", + "codex-model-entitlement-admission.test.ts": "codex-integration", + "codex-routing-cache-affinity-detour.test.ts": "codex-integration", "codex-model-denial-evidence.test.ts": "codex-integration", "codex-model-availability-error.test.ts": "codex-integration", "codex-models-cache-invalidate.test.ts": "codex-integration", @@ -448,6 +451,7 @@ "credential-redirect-guard.test.ts": "lib", "crusoe-provider.test.ts": "providers", "cursor-adapter.test.ts": "providers/cursor", + "cursor-roster-account-scope.test.ts": "providers/cursor", "cursor-continuity-retention.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-catalog.test.ts b/tests/providers/cursor/cursor-catalog.test.ts index 4d8a79e352f..f4b1580ad7f 100644 --- a/tests/providers/cursor/cursor-catalog.test.ts +++ b/tests/providers/cursor/cursor-catalog.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from "bun:test"; import { CURSOR_CAPABILITIES, + cursorLiveRosterScope, cursorUmbrellaRows, parseCursorVariantId, recordLiveCursorClaudeModels, resetLiveCursorClaudeWireIdentitiesForTests, resolveCursorSelection, } from "../../../src/adapters/cursor/catalog"; +import { clearModelCache } from "../../../src/codex/model-cache"; import { cursorEffortSuffix, cursorModelHasEffortTiers, @@ -211,6 +213,30 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = } }); + test("live roster spellings stay isolated by provider credential scope and clear with its cache", () => { + const trusted = cursorLiveRosterScope("https://trusted.cursor.test", "trusted-token"); + const untrusted = cursorLiveRosterScope("https://other.cursor.test", "other-token"); + recordLiveCursorClaudeModels( + ["claude-4.6-opus-high-thinking"], + { provider: "cursor-trusted", key: trusted }, + ); + recordLiveCursorClaudeModels( + ["claude-opus-4-6-thinking-high"], + { provider: "cursor-other", key: untrusted }, + ); + + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: trusted }).wireId) + .toBe("claude-4.6-opus-high-thinking"); + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: untrusted }).wireId) + .toBe("claude-opus-4-6-thinking-high"); + + clearModelCache("cursor-other"); + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: untrusted }).wireId) + .toBe("claude-4.6-opus-high-thinking"); + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: trusted }).wireId) + .toBe("claude-4.6-opus-high-thinking"); + }); + test("ultra arms maxMode only on evidence-gated bases", () => { const kimi = resolveCursorSelection("kimi-k3-1m", "ultra"); expect(kimi.maxMode).toBe(true); diff --git a/tests/providers/cursor/cursor-roster-account-scope.test.ts b/tests/providers/cursor/cursor-roster-account-scope.test.ts new file mode 100644 index 00000000000..f4861962fbe --- /dev/null +++ b/tests/providers/cursor/cursor-roster-account-scope.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { setFetchCursorUsableModelsForTests } from "../../../src/adapters/cursor/live-models"; +import { fetchProviderModels } from "../../../src/codex/catalog/provider-fetch"; +import { clearModelCache, providerCacheGenerations } from "../../../src/codex/model-cache"; +import type { OcxProviderConfig } from "../../../src/types"; + +/** + * The live GetUsableModels roster is plan-specific: it is an observation made + * under one credential and must never be served to another, whether fresh, + * stale, or through a failure cooldown. The scoped spelling and Max-Mode maps + * (#5229) do not cover the cached roster itself; these cases pin the roster + * cache to the credential fingerprint that produced it. + */ + +const PROVIDER = "cursor-roster-scope-test"; + +beforeEach(() => { + clearModelCache(PROVIDER); + providerCacheGenerations.delete(PROVIDER); +}); + +afterEach(() => { + setFetchCursorUsableModelsForTests(null); + clearModelCache(PROVIDER); + providerCacheGenerations.delete(PROVIDER); +}); + +const baseProvider = { + adapter: "cursor", + baseUrl: "https://cursor.roster-scope.test", + authMode: "key", + liveModels: true, + models: ["gpt-5.5", "gpt-5.6-sol"], +} as OcxProviderConfig; + +describe("cursor live roster account scoping", () => { + test("a credential change cannot reuse the previous account's plan roster", async () => { + const calls: string[] = []; + setFetchCursorUsableModelsForTests(async (opts) => { + calls.push(opts.apiKey); + return { ok: true, models: [opts.apiKey === "acct-a-token" ? "gpt-5.5" : "gpt-5.6-sol"] }; + }); + + const accountA = await fetchProviderModels(PROVIDER, { ...baseProvider, apiKey: "acct-a-token" }, 60_000); + const accountB = await fetchProviderModels(PROVIDER, { ...baseProvider, apiKey: "acct-b-token" }, 60_000); + + expect(accountA.map((model) => model.id)).toEqual(["gpt-5.5"]); + expect(accountB.map((model) => model.id)).toEqual(["gpt-5.6-sol"]); + // B went live: serving A's fresh cached roster would have skipped the fetch. + expect(calls).toEqual(["acct-a-token", "acct-b-token"]); + }); + + test("a failed discovery under one credential neither supplies nor suppresses the next", async () => { + const calls: string[] = []; + let fail = false; + setFetchCursorUsableModelsForTests(async (opts) => { + calls.push(opts.apiKey); + if (fail) return { ok: false, error: "auth" as const }; + return { ok: true, models: [opts.apiKey === "acct-a-token" ? "gpt-5.5" : "gpt-5.6-sol"] }; + }); + + // A caches its roster (ttl 0 keeps the fresh window closed so the failure + // below actually reaches the network seam). + await fetchProviderModels(PROVIDER, { ...baseProvider, apiKey: "acct-a-token" }, 0); + // A's next discovery fails, starting the provider cooldown; A degrades to + // its own stale roster. + fail = true; + const failedA = await fetchProviderModels(PROVIDER, { ...baseProvider, apiKey: "acct-a-token" }, 0); + expect(failedA.map((model) => model.id)).toEqual(["gpt-5.5"]); + // B must not read A's stale roster, and A's cooldown must not suppress B's + // first discovery: with no roster of its own, B goes live. + fail = false; + const accountB = await fetchProviderModels(PROVIDER, { ...baseProvider, apiKey: "acct-b-token" }, 0); + + expect(accountB.map((model) => model.id)).toEqual(["gpt-5.6-sol"]); + expect(calls).toEqual(["acct-a-token", "acct-a-token", "acct-b-token"]); + }); +}); diff --git a/tests/providers/devin-live-models.test.ts b/tests/providers/devin-live-models.test.ts index db365e2062c..70d3c8533e3 100644 --- a/tests/providers/devin-live-models.test.ts +++ b/tests/providers/devin-live-models.test.ts @@ -185,4 +185,23 @@ describe("devin advertised catalog input modalities", () => { expect(models.map((model) => model.id)).toEqual(["plain-model"]); expect(models[0]?.inputModalities).toEqual(["text"]); }); + + test("a credential change cannot reuse the previous account's live roster", async () => { + // The live catalog is entitlement-specific: an observation made under one + // credential must not be served to the next. Before the roster cache was + // bound to the credential fingerprint, account B read account A's fresh + // entry and never resolved its own token. + let token = "acct-a-key"; + authSpy?.mockImplementation(async () => token); + setCachedCatalogForTests(parseCatalogBuffer(encodeMessage(1, catalogEntry("acct-a-model")), "acct-a-key", HOST)); + const accountA = await fetchProviderModels("devin-test", devinProvider(), 60_000); + + token = "acct-b-key"; + setCachedCatalogForTests(parseCatalogBuffer(encodeMessage(1, catalogEntry("acct-b-model")), "acct-b-key", HOST)); + const accountB = await fetchProviderModels("devin-test", devinProvider(), 60_000); + + expect(accountA.map((model) => model.id)).toEqual(["acct-a-model"]); + expect(accountB.map((model) => model.id)).toEqual(["acct-b-model"]); + expect(authSpy?.mock.calls.length).toBe(2); + }); }); diff --git a/tests/usage/cache-diagnostic.test.ts b/tests/usage/cache-diagnostic.test.ts new file mode 100644 index 00000000000..4e61d7976d4 --- /dev/null +++ b/tests/usage/cache-diagnostic.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { captureCodexAffinityDiagnostic } from "../../src/codex/affinity-debug"; +import { getDebugLogEntries, resetDebugLogBufferForTests } from "../../src/lib/debug-log-buffer"; +import { resetDebugSettingsForTests, setDebugSettings } from "../../src/lib/debug-settings"; +import { + appendFinalCacheDiagnostic, + cacheDiagnosticPath, + CACHE_DEBUG_KEEP_LINES, + CACHE_DEBUG_MAX_LINES, + observeInbound, + observeOutbound, + rebindCacheDiagnosticBodyAlias, + tagCacheDiagnosticValue, +} from "../../src/usage/cache-diagnostic"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; +let previousDebug: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousDebug = process.env.OPENCODEX_CACHE_DEBUG; + testDir = mkdtempSync(join(tmpdir(), "ocx-cache-debug-")); + process.env.OPENCODEX_HOME = testDir; + resetDebugSettingsForTests(); + resetDebugLogBufferForTests(); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousDebug === undefined) delete process.env.OPENCODEX_CACHE_DEBUG; + else process.env.OPENCODEX_CACHE_DEBUG = previousDebug; + resetDebugSettingsForTests(); + resetDebugLogBufferForTests(); + if (testDir) removeTreeWithRetry(testDir); +}); + +function append( + requestId: string, + draft = observeInbound({}, new Headers()), + cache: { raw?: number; value?: number; provenance?: "observed" | "synthesized" | "unknown" } = {}, +): void { + appendFinalCacheDiagnostic({ + requestId, + protocol: "responses", + provider: "openai", + model: "gpt-test", + ...(cache.raw !== undefined ? { rawCacheCounterValue: cache.raw } : {}), + ...(cache.value !== undefined ? { normalizedCacheValue: cache.value } : {}), + cacheProvenance: cache.provenance ?? "unknown", + draft, + }); +} + +function records(): Array> { + return readFileSync(cacheDiagnosticPath(), "utf8").split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); +} + +interface DiagnosticRecord { + requestId: string; + promptCacheKey: { inbound: { present: boolean; source?: string; tag?: string } }; + session: { inboundHeader: { tag?: string } }; + prefix: { + inbound: { messages: { tags: string[] } }; + outbound: { messages: { tags: string[] } }; + firstDivergentBlock?: { section: string; index: number }; + }; + cache: { + rawUpstream: { present: boolean; value?: number; field?: string }; + normalized: { present: boolean; value?: number; provenance: string }; + }; +} + +function diagnosticRecords(): DiagnosticRecord[] { + return records() as unknown as DiagnosticRecord[]; +} + +describe("cache diagnostic", () => { + test("is disabled by default", () => { + delete process.env.OPENCODEX_CACHE_DEBUG; + append("disabled"); + expect(existsSync(cacheDiagnosticPath())).toBe(false); + }); + + test("writes only presence, process tags, counts, and bounded metadata", () => { + process.env.OPENCODEX_CACHE_DEBUG = "1"; + const cacheKey = "raw-cache-key-sentinel"; + const session = "raw-session-sentinel"; + const prompt = "raw-prompt-sentinel"; + const inbound = { prompt_cache_key: cacheKey, input: [{ role: "user", content: prompt }] }; + const draft = observeInbound(inbound, new Headers({ session_id: session })); + observeOutbound(inbound, inbound, { session_id: session }); + append("privacy", draft); + + const text = readFileSync(cacheDiagnosticPath(), "utf8"); + expect(text).not.toContain(cacheKey); + expect(text).not.toContain(session); + expect(text).not.toContain(prompt); + const record = diagnosticRecords()[0]; + expect(record.promptCacheKey.inbound).toMatchObject({ present: true, source: "caller" }); + expect(record.promptCacheKey.inbound.tag).toMatch(/^[0-9a-f]{12}$/); + expect(record.session.inboundHeader.tag).toMatch(/^[0-9a-f]{12}$/); + expect(record.prefix.inbound.messages.tags[0]).toMatch(/^[0-9a-f]{12}$/); + if (process.platform !== "win32") expect(statSync(cacheDiagnosticPath()).mode & 0o777).toBe(0o600); + }); + + test("fingerprints equal bodies equally and locates the first changed message", () => { + process.env.OPENCODEX_CACHE_DEBUG = "1"; + const first = { input: [{ role: "user", content: "first" }] }; + const same = { input: [{ role: "user", content: "first" }] }; + const changed = { input: [{ role: "user", content: "changed" }] }; + const equalDraft = observeInbound(first, new Headers()); + observeOutbound(first, same, {}); + append("equal", equalDraft); + const changedDraft = observeInbound(first, new Headers()); + observeOutbound(first, changed, {}); + append("changed", changedDraft); + + const [equal, different] = diagnosticRecords(); + expect(equal.prefix.inbound.messages.tags).toEqual(equal.prefix.outbound.messages.tags); + expect(equal.prefix.firstDivergentBlock).toBeUndefined(); + expect(different.prefix.firstDivergentBlock).toEqual({ section: "messages", index: 0 }); + }); + + test("a rebuilt request body stays bound to the same draft through an alias", () => { + process.env.OPENCODEX_CACHE_DEBUG = "1"; + // The previous-response expansion rebuilds the body object after the inbound + // observation; the adapter seam sees only the rebuilt one. + const inbound = { input: [{ role: "user", content: "literal-inbound" }] }; + const rebuilt = { input: [{ role: "user", content: "literal-inbound" }, { role: "user", content: "expanded" }] }; + const draft = observeInbound(inbound, new Headers()); + rebindCacheDiagnosticBodyAlias(rebuilt, draft); + observeOutbound(rebuilt, rebuilt, {}); + append("aliased", draft); + + const [record] = diagnosticRecords(); + expect(record.prefix.inbound.messages.tags).toHaveLength(1); + expect(record.prefix.outbound.messages.tags).toHaveLength(2); + expect(record.prefix.firstDivergentBlock).toEqual({ section: "messages", index: 1 }); + }); + + test("observation never mutates the live request body", () => { + process.env.OPENCODEX_CACHE_DEBUG = "1"; + // An array-valued instructions field aliases the body's own array in the block + // splitter; appending system/developer content into it would rewrite the request + // the adapter is about to send upstream. + const body = { + instructions: ["standing-instruction"], + input: [ + { role: "system", content: "system-note" }, + { role: "user", content: "hello" }, + ], + }; + const before = JSON.stringify(body); + const draft = observeInbound(body, new Headers()); + observeOutbound(body, body, {}); + append("immutability", draft); + + expect(JSON.stringify(body)).toBe(before); + const [record] = diagnosticRecords(); + expect(record.prefix.inbound.messages.tags).toHaveLength(1); + }); + + test("keeps an observed upstream zero distinct from an absent counter", () => { + process.env.OPENCODEX_CACHE_DEBUG = "1"; + append("zero", observeInbound({}, new Headers()), { raw: 0, value: 0, provenance: "observed" }); + append("absent", observeInbound({}, new Headers()), { provenance: "unknown" }); + + const [zero, absent] = diagnosticRecords(); + expect(zero.cache.rawUpstream).toEqual({ present: true, value: 0 }); + expect(zero.cache.normalized).toEqual({ present: true, value: 0, provenance: "observed" }); + expect(absent.cache.rawUpstream).toEqual({ present: false }); + expect(absent.cache.normalized).toEqual({ present: false, provenance: "unknown" }); + }); + + test("retains the newest records after crossing the rolling limit", () => { + process.env.OPENCODEX_CACHE_DEBUG = "1"; + for (let index = 0; index <= CACHE_DEBUG_MAX_LINES; index += 1) append(`request-${index}`); + const kept = diagnosticRecords(); + expect(kept).toHaveLength(CACHE_DEBUG_KEEP_LINES); + expect(kept[0].requestId).toBe(`request-${CACHE_DEBUG_MAX_LINES + 1 - CACHE_DEBUG_KEEP_LINES}`); + expect(kept.at(-1)?.requestId).toBe(`request-${CACHE_DEBUG_MAX_LINES}`); + }); + + test("uses tags that cannot be joined to affinity-debug output", () => { + const value = "same-private-value"; + setDebugSettings({ debug: true }); + captureCodexAffinityDiagnostic({ + inboundHeaders: new Headers({ session_id: value }), + outboundHeaders: {}, + authKind: "pool", + accountMode: "pool", + fixedAccount: false, + credentialSubstituted: false, + accountGatedModel: false, + wireModelNormalized: false, + status: 200, + }); + const line = getDebugLogEntries().at(-1)!.line; + const payload = JSON.parse(line.slice("[ocx:codex:affinity] ".length)) as { inbound: Array<{ tag: string }> }; + expect(tagCacheDiagnosticValue("session_id", value)).toMatch(/^[0-9a-f]{12}$/); + expect(tagCacheDiagnosticValue("session_id", value)).not.toBe(payload.inbound[0].tag); + }); +}); From 29e796b0a532d7bf57a8b1a692882a882c3acccf Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 18:15:39 +0900 Subject: [PATCH 12/87] fix(codex): close the silent paths into a Codex lockout (#5288) * fix(codex): stop the Codex shim hiding a failed autostart, and never block Codex The shim ran "ocx ensure" with both streams discarded and its exit status ignored, then launched Codex regardless. A proxy that failed to come up was therefore completely silent, and Codex started against injected routing pointing at a port nothing was listening on -- the #5261 shape, with nothing on screen naming opencodex. It now checks the exit status and prints one line on stderr when the start failed, naming "ocx doctor" and "ocx restore". Ensure's own streams stay discarded: it prints progress and warnings on exit-zero runs too, and a wrapper that leaked those would put noise in front of every ordinary launch, which is how a diagnostic gets ignored. The exit status is the signal, and the one line is the whole message. PowerShell had the opposite defect in the same place. An "ensure" that threw escaped the try/finally, which has no catch, so Codex never launched at all -- the autostart helper creating the exact lockout it exists to prevent. That path now catches, reports, and hands over to the real launcher, with Codex's exit status still authoritative. The Unix revision marker moves to 3 so installed Unix shims are detected as obsolete and regenerated. Windows shims have no revision marker and are excluded from obsolete-shim refresh, so existing Windows wrappers keep the old text until reinstalled; that gap is recorded in the lane document rather than papered over here. The new behaviour is covered by running the generated script against a stand-in ensure, in a sibling file because codex-shim.test.ts is close to its line cap and the cap only moves down. The test constants it kept as private literal copies now come from the source module. * fix(cli): report a Codex catalog pointer whose file is gone A model_catalog_json naming a file that no longer exists does not degrade Codex, it stops Codex loading its configuration at all. That presents as the same blank wall as the dead routing in #5261 while having a different cause and a different fix, and it is the state the reporter's machine was left in after the catalog file was deleted by hand. Injection already repairs this: the chooser refuses a missing owned path and the caller strips the stale line. My earlier note that the pointer survived injection was wrong, and the end-to-end coverage for it already exists. What was missing is that the repair only reaches someone who runs opencodex again, and the whole difficulty of this state is that Codex is the thing that stopped working, so nothing prompts them to. "ocx status" now says it out loud, names the file, and offers both outcomes: regenerate the catalog with "ocx start", or take opencodex out of Codex with "ocx restore". Which one they want is their choice. A catalog the user named is left alone whether or not it exists, exactly as during injection. Claiming it would put opencodex's recovery advice in front of a problem that is not opencodex's to explain. An unreadable or absent config reports nothing rather than inventing a finding. * fix(cli): say at setup time that Codex routing outlives the proxy Applying the Codex integration writes routing that survives a restart, and then setup ends on "Setup complete". It does not install a background service -- that is a separate command -- so "routing written, nothing listening" is an ordinary state after the next reboot rather than a corruption. Nobody says so, which is half of why #5261 read as a Codex fault rather than an opencodex one. Setup now ends by reusing the existing restart-health model: when the install is restart-unsafe it prints what status and doctor already say about it, and names "ocx restore" as the way out that does not need the proxy back. It runs after the autostart choice, because that choice is what decides whether the warning applies, and a diagnostic that cannot be computed does not fail a completed setup. Deliberately not a Windows boot trigger. The scheduled task runs as the interactive user, so before logon there is no session for it to run in; adding a BootTrigger would read like a fix and change nothing. Making it genuinely pre-logon means a different principal and a different service backend, which is a larger change than this lane, and is recorded as such rather than half-done here. * fix(oauth): report a browser launch that never happened The URL launcher swallowed its own failure and returned nothing, so the Codex login route answered identically whether a browser opened, failed to open, or was deliberately skipped. The CLI then printed the URL and started polling, and a user whose machine could not launch a browser sat watching something that looked like it was working. That is the account-pool half of #5261: not an error, a silence. openUrl now resolves a result instead of returning void. It still never rejects and never throws, because a browser that will not open is an inconvenience rather than a login failure -- the URL remains a valid thing to open by hand and the flow stays live. Callers that genuinely do not care say so with void. The Codex login response carries browserLaunch, and the CLI prints a recovery line only when the launch failed. It names the fixed callback port, because that is the part a user cannot work out alone: ChatGPT supplies the redirect URI, so the flow cannot move to a free port, and --device is the way around it. Existing tests mocked openUrl as returning void, which the awaiting caller would have read as a failed launch; all eight mock sites now resolve a result. The new test does not exercise the started case: the launcher command is fixed per platform, so proving it would mean opening a real browser on whatever machine runs the suite. * docs(devlog): record lane H2, the remaining lockout dead ends Explains why the four paths belong in one lane: none locks anyone out alone, and the incident is what happens when every signal is missing at once. Records two corrections rather than burying them -- lane H's claim that the catalog pointer survives injection was wrong, and a Windows BootTrigger is a fake fix given the task's interactive principal -- plus the three residuals this lane deliberately leaves open. * test(ci): record the new shim test's cold-spawn disposition Hosted CI failed on the guard that every test file bounding a spawned child with the internal deadline must declare whether it warms that child's module graph. The new shim test spawns children and did not. Recorded as unwarmed, for the same reason as the file it sits beside: its children are throwaway shell scripts standing in for ensure and for the real Codex launcher, so the cold cost is shell and process startup rather than a repository module graph, and an import scan has nothing to warm. The generated shim never loads a repository module in the child -- the point of the file is what the shell does with an exit status. * fix: close the gaps an adversarial review found in this lane Blocker: the PowerShell shim test's Codex-failure fixture also exited 19 from ensure, which the wrapper now correctly reports as a failed autostart, so that phase no longer isolated a Codex failure and both PowerShell variants would have failed on Windows. Its ensure now succeeds, which is what the phase always meant. The setup warning said "nothing here will restart the proxy" for every restart-unsafe install. A healthy launcher shim is also restart-unsafe, because it covers CLI launches only, but it does restart the proxy for those -- and the summary line printed directly beneath said so. The warning now states the dependency without the false absolute, and a test pins that the shim case does not claim otherwise. The URL launcher resolved "started" on the spawn event, which only proves a process began. A launcher with no handler spawns happily and exits nonzero a moment later without opening anything, so a login could still report a launch that never happened. It now watches briefly for an immediate nonzero exit, which is how those failures arrive. "ocx gui" awaits the launch and says so when it did not happen. It still exits 0: the proxy is serving and the URL it printed is reachable, only the launch failed. The catalog finding is tagged (local) on its header like every other local-state line, so a connected client cannot read a finding about its own Codex home as something the hub reported. Catalog ownership stays decided by basename, now stated as a choice rather than left to look like an oversight: it is the same test injection applies, and a detector drawing the line elsewhere would report a state injection would then treat differently. Also removes the temporary directory the launcher test created, and corrects that file's header to describe what it actually covers. --- .../030_lane_h2_lockout_dead_ends.md | 90 ++++++++++++++ scripts/test-layout/layout.json | 2 + src/cli/account-auth.ts | 15 +++ src/cli/dispatch.ts | 6 +- src/cli/index.ts | 8 +- src/cli/init.ts | 8 ++ src/cli/status.ts | 35 ++++++ src/codex/auth-api/login-flow.ts | 7 +- src/codex/autostart-health.ts | 28 +++++ src/codex/inject/config-toml.ts | 29 +++++ src/codex/shim-templates.ts | 32 ++++- src/lib/open-url.ts | 58 +++++++-- src/oauth/login-cli.ts | 4 +- src/server/management/oauth-account-routes.ts | 2 +- tests/ci-workflows/cold-spawn-warmup.test.ts | 8 ++ .../codex-integration/codex-auth-api.test.ts | 12 +- .../codex-shim-ensure-failure.test.ts | 117 ++++++++++++++++++ tests/codex-integration/codex-shim.test.ts | 18 ++- .../codex-signin-lockout.test.ts | 58 ++++++++- tests/fixtures/test-layout-expected.json | 2 + tests/oauth/oauth-login-open-browser.test.ts | 2 +- tests/oauth/oauth-open-browser-choice.test.ts | 2 +- tests/oauth/oauth-open-url-result.test.ts | 55 ++++++++ tests/service/autostart-health.test.ts | 33 ++++- 24 files changed, 599 insertions(+), 32 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/030_lane_h2_lockout_dead_ends.md create mode 100644 tests/codex-integration/codex-shim-ensure-failure.test.ts create mode 100644 tests/oauth/oauth-open-url-result.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/030_lane_h2_lockout_dead_ends.md b/devlog/_plan/260920_meaning_preservation_batch/030_lane_h2_lockout_dead_ends.md new file mode 100644 index 00000000000..d1c6f7c1d94 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/030_lane_h2_lockout_dead_ends.md @@ -0,0 +1,90 @@ +# Lane H2 — the remaining Codex lockout dead ends (#5261) + +Status: OPEN. Base is `origin/dev` at `043aa435ff`, after lane H landed as `9880c3cad2`. +One branch, ordered commits, one pull request to `dev`. + +Lane H removed the dead end a locked-out user hits: injected routing now names its own undo, +a dead proxy is told to say so, and there is a troubleshooting page. This lane takes the four +paths INTO that state which lane H listed as not closed. + +## Why these four belong together + +None of them locks anyone out alone. Each one removes a signal, and the incident is what +happens when all of them are missing at once: a proxy that stopped, an autostart that failed +silently, a reboot that restarted nothing, a catalog pointer left naming a deleted file, and an +account flow that looked like it was working. Every individual step had an explanation; the +user had no way to reach any of them. + +## 1. The shim hid autostart failure, and could prevent Codex launching + +The wrapper ran `ocx ensure` with both streams discarded and `|| true`, then launched Codex +regardless (`src/codex/shim-templates.ts:139`). A failed start was invisible. + +Ensure's own streams stay discarded rather than being let through. It prints progress and +warnings on exit-zero runs too, so a wrapper that leaked them would put noise in front of every +ordinary launch, which is how a diagnostic gets ignored. The exit status is the signal. + +PowerShell had the opposite defect in the same place: a throwing `ensure` escaped a `try/finally` +with no `catch`, so Codex never launched at all. The autostart helper was producing the exact +lockout it exists to prevent, and the previous test asserted that propagation as correct +behaviour. It now catches, reports, and hands over. + +Not closed: the Unix revision marker moved to 3 so installed Unix shims regenerate, but Windows +shims carry no revision marker and are excluded from obsolete-shim refresh +(`src/codex/shim.ts:892`). Existing Windows wrappers keep the old text until reinstalled. Giving +Windows a refresh path is its own change. + +## 2. The catalog pointer — my lane H note was wrong + +Lane H recorded that nothing revalidates `model_catalog_json` after injection. That is not true. +The chooser refuses a missing owned path and the caller strips the stale line +(`src/codex/inject.ts:327`), with end-to-end coverage already in place. + +The real gap is narrower and worse: that repair only reaches someone who runs opencodex again, +and the difficulty of this state is that Codex is the thing that stopped working, so nothing +prompts them to. A `model_catalog_json` naming a file that is gone does not degrade Codex, it +stops Codex loading its configuration at all — the same blank wall as dead routing, from a +different cause. So this lane adds detection, not repair, and `ocx status` now names the file +and both ways out. + +## 3. Reboot: stated, not faked + +Applying the integration does not install a service, and the Windows scheduled task a separate +install would create is logon-triggered (`src/service/windows-taskxml.ts:225`). Setup ended on a +success line without ever saying routing outlives the proxy. + +A BootTrigger is deliberately NOT the fix. The task runs as the interactive user +(`LogonType: InteractiveToken`), so before logon there is no session for it to run in; the +trigger would read like a fix and change nothing. Genuine pre-logon start means a different +principal and a different service backend, which is larger than this lane and is left open +rather than half-done. What was cheap and true on every platform is saying the dependency +exists, reusing the health model `ocx status` and `ocx doctor` already report. + +## 4. Account pool: a silence, not an error + +The URL launcher swallowed its own failure and returned nothing, so the Codex login route +answered identically whether a browser opened, failed, or was skipped. The CLI printed the URL +and polled, and a user whose machine could not launch a browser watched something that looked +like it was working. + +Launch failure is now reported and never fatal — the URL is still worth opening by hand and the +flow stays live. The CLI names the fixed callback port 1455, because ChatGPT supplies the +redirect URI and the flow cannot move to a free port, so `--device` is the way around it. + +Not closed: the dashboard keeps last-good rows after a failed refresh +(`gui/src/hooks/useCodexAccountPool.ts:325`), which is why a newly added account can be absent +while older rows still show. That is a GUI change, and the pull-request gate requires a +screenshot of a UI change, which cannot be produced under this lane's no-build constraint. It +is left for a lane that can build the GUI. + +## Verification + +Static source review and exact-head hosted CI only. NOT RUN, by lane constraint: local suite, +individual tests, typecheck, build, install, `ocx` execution, service start or restart, and any +change to credentials or configuration on the machine. The incident being fixed is a +configuration change that locked a user out. + +Checked before push: the file-size ratchet (no tracked file over cap; `codex-shim.test.ts` +sits close to its cap, so the new shim tests went to a sibling file registered in both +layout maps), and the restated-constant class that broke two lanes in this batch — the shim +test's private literal copies of the marker constants now come from the source module. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7bbb92e15db..2a5d06246c3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -563,6 +563,7 @@ "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", + "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", @@ -1022,6 +1023,7 @@ "oauth-login-summary.test.ts": "oauth", "oauth-manual-code.test.ts": "oauth", "oauth-open-browser-choice.test.ts": "oauth", + "oauth-open-url-result.test.ts": "oauth", "oauth-provider-reconcile.test.ts": "oauth", "oauth-public-surface.test.ts": "oauth", "oauth-reauth-bind.test.ts": "oauth", diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 73838f9da8e..de6364eb05f 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -69,8 +69,22 @@ interface LoginStart { flowId?: string; instructions?: string; deviceCode?: string; + /** Whether the host actually opened a browser. Absent from older proxies. */ + browserLaunch?: "started" | "failed" | "skipped"; } +/** + * Said only when the host could not open a browser (#5261). + * + * Without it, a failed launch is indistinguishable from a successful one: the URL is printed + * either way, so the user waits at a terminal that looks like it is working. Names the fixed + * callback port because that is the part people cannot guess — ChatGPT supplies the redirect + * URI, so the flow cannot move to a free port, and `--device` is the way around it. + */ +export const BROWSER_LAUNCH_FAILED_HINT = + "⚠️ No browser could be opened here — open the URL above yourself." + + "\n If nothing on this machine can reach http://localhost:1455, rerun with --device instead."; + /** `-` means "read it from stdin", the documented way to pass a code silently. */ const STDIN_SENTINEL = "-"; @@ -145,6 +159,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { start.url ? `Open this URL to sign in:\n${start.url}` : "", start.deviceCode ? `Device code: ${start.deviceCode}` : "", start.instructions ?? "", + start.browserLaunch === "failed" ? BROWSER_LAUNCH_FAILED_HINT : "", start.flowId ? `Flow: ${start.flowId}` : "", ].filter(line => line !== "").join("\n"); if (block) writeStdoutFully(`${block}\n`); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 029336f66c9..b9b09420c87 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -615,7 +615,11 @@ const commandRunners: Record = { const guiUrl = selectDefaultGuiUrl(config, live, deps.probeHostname); console.log(`Opening ${guiUrl}`); const { openUrl } = await import("../lib/open-url"); - openUrl(guiUrl); + // Awaited so a launcher that never opened anything is said out loud (#5261). Still exit + // 0: the proxy is serving and the URL above is reachable, only the launch did not happen. + if ((await openUrl(guiUrl)).status === "failed") { + console.error("⚠️ No browser could be opened here; open the URL above yourself."); + } return 0; }, }); diff --git a/src/cli/index.ts b/src/cli/index.ts index d65ce7dc962..98843494e44 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -63,7 +63,7 @@ import { pendingTeardownsAreExactly, quarantinePendingTeardown, } from "../config/pending-teardown"; -import { collectStatus, deadProxyRoutingAdviceLines, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; +import { collectStatus, deadProxyRoutingAdviceLines, detectMissingCodexCatalogPath, hubStatusLines, missingCodexCatalogLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; import { parseStartOptions, StartArgsError } from "./start-args"; @@ -1614,6 +1614,12 @@ async function handleStatus() { console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}${local}`); console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}${local}`); console.log(` ${formatStartupRoutingDetail(status.json.startup)}${local}`); + // Independent of whether the proxy is up: a catalog pointer whose file is gone stops Codex + // loading its config at all, and presents as the same blank wall as dead routing (#5261). + // Tagged `(local)` on its header like every other local-state line, so a connected client + // cannot read a finding about its own Codex home as something the hub reported. + missingCodexCatalogLines(detectMissingCodexCatalogPath()) + .forEach((line, index) => console.log(` ${line}${index === 0 ? local : ""}`)); if (status.json.startup.routingKind === "native") { let retainedProviderTable = false; try { diff --git a/src/cli/init.ts b/src/cli/init.ts index f310b7f7966..e53b203415a 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -253,6 +253,14 @@ export async function runInit(): Promise { } console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`); + // Said after the autostart choice, because the choice is what decides whether it applies. + // Setup otherwise ends on a success line while leaving a restart dependency unmentioned. + try { + const { collectStartupHealth, injectedRoutingRestartWarningLines } = await import("../codex/autostart-health"); + for (const line of injectedRoutingRestartWarningLines(collectStartupHealth(config))) console.log(line); + } catch { + // A diagnostic that cannot be computed must not fail a completed setup. + } for (const line of modelSelectionGuidance(providerName)) console.log(line); } catch (error) { if (error instanceof InitCancelledError) { diff --git a/src/cli/status.ts b/src/cli/status.ts index 22d1e25871c..9079a2b5f90 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,4 +1,5 @@ import { durableBunRuntime } from "../lib/bun-runtime"; +import { existsSync, readFileSync } from "node:fs"; import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config"; import { getPidPath, readPid, readRuntimePort, type RuntimePortState } from "../config/process-state"; import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor"; @@ -7,6 +8,8 @@ import type { OcxConfig } from "../types"; import { diagnoseService, serviceLogPath } from "../service"; import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health"; import { getCodexRoutingKind } from "../codex/inject"; +import { missingOwnedCatalogPath } from "../codex/inject/config-toml"; +import { CODEX_CONFIG_PATH } from "../codex/paths"; import { diagnoseCodexShim } from "../codex/shim"; import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime"; import { packageVersion } from "./help"; @@ -529,6 +532,38 @@ export function deadProxyRoutingAdviceLines(input: { ]; } +/** + * Read the live Codex config and report an opencodex catalog pointer whose file is gone. + * + * Unreadable or absent config is reported as no finding rather than as a problem: this is a + * diagnostic line, and inventing one from missing evidence is worse than staying quiet. + */ +export function detectMissingCodexCatalogPath(): string | null { + try { + if (!existsSync(CODEX_CONFIG_PATH)) return null; + return missingOwnedCatalogPath(readFileSync(CODEX_CONFIG_PATH, "utf8")); + } catch { + return null; + } +} + +/** + * The one state in this report where Codex is broken independently of the proxy (#5261). + * + * A `model_catalog_json` naming a file that is gone stops Codex loading its configuration at + * all, so it presents as the same blank wall as dead routing while having a different cause and + * a different fix. Both are named, because restarting the proxy rewrites the catalog and + * restoring removes the pointer, and which one the user wants is their choice, not ours. + */ +export function missingCodexCatalogLines(missingCatalogPath: string | null): string[] { + if (!missingCatalogPath) return []; + return [ + "⚠️ Codex is pointed at a model catalog that is no longer on disk, so Codex cannot load its config:", + ` ${missingCatalogPath}`, + " Regenerate it with 'ocx start', or remove opencodex from Codex with 'ocx restore'.", + ]; +} + export async function collectStatus(): Promise { const configDiagnostics = readConfigDiagnostics(); const config = configDiagnostics.config; diff --git a/src/codex/auth-api/login-flow.ts b/src/codex/auth-api/login-flow.ts index fa6d1c6fbf7..3a9111e2a4d 100644 --- a/src/codex/auth-api/login-flow.ts +++ b/src/codex/auth-api/login-flow.ts @@ -226,9 +226,12 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig, // machine. Opening it on the hub host is useless at best, and on a // headless host it fails. `deviceCode` is the same signal the generic // OAuth login route uses to make this decision. + // Reported to the caller rather than discarded (#5261): a login whose browser never opened + // is indistinguishable from one that did, so it reads as success while nothing happens. + let browserLaunch: "started" | "failed" | "skipped" = "skipped"; if (result.url && !result.deviceCode && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) { const { openUrl } = await import("../../lib/open-url"); - openUrl(result.url); + browserLaunch = (await openUrl(result.url)).status === "started" ? "started" : "failed"; } (async () => { @@ -488,6 +491,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig, flowId, url: result.url, instructions: result.instructions, + // Never fatal: the URL is still a valid thing to open by hand, and the flow stays live. + browserLaunch, // Dropped before #3366: every device-code surface renders this field, // so withholding it left the GUI and CLI with no code to show. ...(result.deviceCode ? { deviceCode: result.deviceCode } : {}), diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index 5fc990a5f6c..83b330ca3db 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -154,6 +154,34 @@ export function startupHealthSummary(health: StartupHealth): string { return action ? `${summary}; ${action}` : summary; } +/** + * What to say at the end of setup, once routing is on disk and the autostart choice is made. + * + * #5261: applying the Codex integration does not install a background service, and on Windows + * the scheduled task that a separate install would create is logon-triggered rather than + * boot-triggered. So "routing written, nothing listening" is an ordinary state after a restart + * rather than a corruption — and the user is never told, because setup ends on a success line. + * + * A boot trigger is not the missing piece and would be a false reassurance. The task runs as + * the interactive user, so before logon there is no session for it to run in; making it truly + * pre-logon means a different principal and a different service backend, not another trigger. + * What is actually missing is that nobody says the dependency exists, which is cheap to fix and + * true on every platform. + * + * Reuses the existing health model rather than re-deriving the condition, so this cannot drift + * from what `ocx status` and `ocx doctor` report about the same install. + */ +export function injectedRoutingRestartWarningLines(health: StartupHealth): string[] { + if (health.status !== "at-risk") return []; + return [ + // Deliberately not "nothing will restart the proxy": a healthy launcher shim does restart it, + // for CLI launches only, and is still at-risk. The summary line below says which case this is. + "⚠️ Codex routing is written to disk and survives a restart; keeping the proxy running is a separate matter.", + ` ${startupHealthSummary(health)}`, + " While the proxy is down Codex cannot sign in or reach a model. 'ocx restore' undoes the routing without needing it.", + ]; +} + function classifyStartupHealthSummary(health: StartupHealth): string { if (health.status === "native") return health.routingKind === "custom-remote" ? "custom remote Codex routing (no local restart dependency)" diff --git a/src/codex/inject/config-toml.ts b/src/codex/inject/config-toml.ts index 7577e2c6813..1e552dbbef3 100644 --- a/src/codex/inject/config-toml.ts +++ b/src/codex/inject/config-toml.ts @@ -609,3 +609,32 @@ export function chooseCatalogPathForInjection( return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null; } + +/** + * The effective `model_catalog_json` is one of ours and the file is gone. + * + * Codex does not degrade on this: a catalog path it cannot read stops it loading its + * configuration at all, which looks exactly like the routing lockout in #5261 and is what the + * reporter's machine was left in after the catalog file was deleted by hand. + * + * Injection already repairs it — the chooser refuses a missing owned path and the caller strips + * the stale line. That only helps someone who runs opencodex again, and the whole difficulty of + * this state is that Codex is the thing that stopped working, so nothing prompts them to. This + * predicate exists so the CLI can say it out loud. + * + * A user-owned catalog assignment wins here exactly as it does during injection: if they named + * the file, its absence is theirs to explain, and we do not claim it. + * + * Ownership is decided by basename, which is a weak test — a file the user happens to name + * `opencodex-catalog.json` is read as ours wherever it sits. That is deliberate rather than + * overlooked: it is the same test injection already applies, and a detector that drew the line + * somewhere else would report a state injection would then treat differently. Tightening it is a + * change to injection, not to this. + */ +export function missingOwnedCatalogPath(content: string): string | null { + const existing = readRootModelCatalogPath(content); + if (!existing) return null; + const resolved = resolveCodexConfigPath(existing); + if (!isOpencodexCatalogPath(resolved)) return null; + return existsSync(resolved) ? null : existing; +} diff --git a/src/codex/shim-templates.ts b/src/codex/shim-templates.ts index 5ed2cb6b4a1..0f5af00d022 100644 --- a/src/codex/shim-templates.ts +++ b/src/codex/shim-templates.ts @@ -4,11 +4,29 @@ import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { windowsEnvIndirectBatchValue } from "../lib/win-paths"; const SHIM_MARKER = "opencodex codex autostart shim"; -const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; +const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 3"; const CODEX_SHIM_REENTRY_EXIT_CODE = 126; const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart."; +/** + * Said once, on stderr, when `ocx ensure` could not bring the proxy up (#5261). + * + * The shim used to discard both of ensure's streams and ignore its exit status, so a failed + * autostart was completely silent: Codex launched against injected routing pointing at a port + * nothing was listening on, and every request — sign-in included — failed with no mention of + * opencodex anywhere. + * + * Ensure's own streams stay discarded rather than being let through. Ensure prints progress and + * warnings on exit-zero runs too, and a wrapper that leaked those would put noise in front of + * every ordinary Codex launch, which is how a diagnostic gets ignored. The exit status is the + * signal; this line is the whole message. + * + * It names `ocx restore` because bringing the proxy back is only half the choice. A user who + * cannot sign in needs the way out that does not require the proxy at all. + */ +export const CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC = "opencodex: proxy autostart failed; launching Codex anyway. Run 'ocx doctor' for details, or 'ocx restore' to hand Codex back to its own account."; + const CODEX_INTERNAL_COMMANDS = [ "app-server", "archive", @@ -136,7 +154,9 @@ case "$ocx_subcommand" in ;; *) if [ -z "$OCX_SHIM_BYPASS" ]; then - ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true + if ! ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1; then + printf '%s\\n' ${shQuote(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)} >&2 + fi fi ;; esac @@ -197,6 +217,7 @@ setlocal\r ${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r ${windowsBatchSet(BUN_RUNTIME_PATH_ENV, bunPath)}\r "%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r +if errorlevel 1 echo ${CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC} 1>&2\r endlocal\r :run_codex\r "%OCX_REAL_CODEX%" %*\r @@ -239,13 +260,18 @@ if (-not $skipEnsure) { $priorRuntimePath = $env:${BUN_RUNTIME_PATH_ENV} $env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)} $env:${BUN_RUNTIME_PATH_ENV} = ${psString(bunPath)} - try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null } + $ocxEnsureFailed = $false + # Caught, not propagated: a throwing ensure used to escape this wrapper and Codex never + # launched at all, which is a lockout produced by the autostart helper itself (#5261). + try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null; if ($LASTEXITCODE -ne 0) { $ocxEnsureFailed = $true } } + catch { $ocxEnsureFailed = $true } finally { if ($null -eq $priorRuntimeSource) { Remove-Item Env:\\${BUN_RUNTIME_SOURCE_ENV} -ErrorAction SilentlyContinue } else { $env:${BUN_RUNTIME_SOURCE_ENV} = $priorRuntimeSource } if ($null -eq $priorRuntimePath) { Remove-Item Env:\\${BUN_RUNTIME_PATH_ENV} -ErrorAction SilentlyContinue } else { $env:${BUN_RUNTIME_PATH_ENV} = $priorRuntimePath } } + if ($ocxEnsureFailed) { [Console]::Error.WriteLine(${psString(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)}) } } & ${psString(realCodexPath)} @args $codexExitCode = $LASTEXITCODE diff --git a/src/lib/open-url.ts b/src/lib/open-url.ts index ae6cb900362..a13287ccf6e 100644 --- a/src/lib/open-url.ts +++ b/src/lib/open-url.ts @@ -8,8 +8,33 @@ function windowsRundll32(): string { return existsSync(candidate) ? candidate : "rundll32"; } -export function openUrl(url: string): void { - if (!/^https?:\/\//i.test(url)) return; +/** + * Whether the OS launcher actually started. `started` does not prove a browser rendered the + * page — nothing observable from here can — but it does separate "we handed the URL off" from + * "there was nothing to hand it to", which is the distinction a caller needs (#5261). + */ +export type OpenUrlResult = + | { status: "started" } + | { status: "failed"; reason: "invalid-url" | "spawn-error" | "launcher-exit" }; + +/** + * How long to watch a launcher that did spawn before calling it started. + * + * `spawn` only proves a process began. `xdg-open` with no desktop handler, and rundll32 given a + * broken association, both spawn happily and exit nonzero a moment later without opening + * anything — so resolving on `spawn` alone would report a launch that did not happen. Those + * failures are immediate, and the only caller that awaits this is a login start, so a short + * window buys a true answer cheaply. A launcher still running when it elapses has started. + */ +const LAUNCHER_SETTLE_MS = 400; + +/** + * Never rejects. A browser that would not open is an inconvenience, not a login failure: the + * URL is still a valid thing to open by hand, so the caller decides what to say about it. + * Callers that genuinely do not care use `void openUrl(...)`. + */ +export function openUrl(url: string): Promise { + if (!/^https?:\/\//i.test(url)) return Promise.resolve({ status: "failed", reason: "invalid-url" }); const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? windowsRundll32() @@ -17,9 +42,28 @@ export function openUrl(url: string): void { const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url]; - const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false }); - // Headless hosts (no xdg-open) emit ENOENT as an async 'error' event; without a - // listener that is an uncaught exception that kills the whole proxy/login flow. - child.on("error", () => {}); - child.unref(); + return new Promise(resolve => { + let settled = false; + let timer: ReturnType | undefined; + const settle = (result: OpenUrlResult) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(result); + }; + const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false }); + // Headless hosts (no xdg-open) emit ENOENT as an async 'error' event; without a + // listener that is an uncaught exception that kills the whole proxy/login flow. + // It is also the signal itself: on Windows this is how a missing rundll32 arrives. + child.on("error", () => settle({ status: "failed", reason: "spawn-error" })); + child.on("spawn", () => { + // Unref'd either way: this never keeps the process alive, it only decides what to report. + timer = setTimeout(() => settle({ status: "started" }), LAUNCHER_SETTLE_MS); + timer.unref?.(); + }); + child.on("exit", code => settle(code === 0 || code === null + ? { status: "started" } + : { status: "failed", reason: "launcher-exit" })); + child.unref(); + }); } diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 45db1af0ecf..19ad8f833cd 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -106,7 +106,7 @@ async function handleOAuthLogin(name: string): Promise { onAuth: ({ url, instructions }) => { console.log(`\n🔐 Opening browser for ${name} login...\n${url}\n`); if (instructions) console.log(instructions); - openUrl(url); + void openUrl(url); }, onProgress: (m) => console.log(` ${m}`), onManualCodeInput: () => @@ -201,7 +201,7 @@ async function handleKeyLogin(name: string): Promise { process.exit(1); } console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`); - openUrl(def.dashboardUrl); + void openUrl(def.dashboardUrl); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const key = (await new Promise((res) => rl.question(`Paste your ${def.label} API key: `, res))).trim(); // Template URL with placeholders needs resolution before saving. diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 0cb29ff8acd..0c03a96d3da 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -210,7 +210,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { shouldOpenBrowserForLogin } = await import("../../oauth/open-browser-choice"); if (authUrl && !deviceCode && shouldOpenBrowserForLogin(body.openBrowser, config)) { const { openUrl } = await import("../../lib/open-url"); - openUrl(authUrl); + void openUrl(authUrl); } return jsonResponse({ url: authUrl, instructions, deviceCode }); } catch (err) { diff --git a/tests/ci-workflows/cold-spawn-warmup.test.ts b/tests/ci-workflows/cold-spawn-warmup.test.ts index 7e969d8cf2a..29e86fdc944 100644 --- a/tests/ci-workflows/cold-spawn-warmup.test.ts +++ b/tests/ci-workflows/cold-spawn-warmup.test.ts @@ -78,6 +78,14 @@ const DISPOSITIONS: Readonly> = { warmed: true, why: "the first resetAt iteration loads src/codex/quota.ts and src/codex/main-account-cache.ts", }, + "tests/codex-integration/codex-shim-ensure-failure.test.ts": { + warmed: false, + why: + "Its children are two throwaway /bin/sh scripts standing in for ensure and for the real Codex " + + "launcher, so the cold cost is shell and process startup rather than a repository module " + + "graph, and an import scan has nothing to warm. The generated shim never loads a repository " + + "module in the child: the point of the file is what the shell does with an exit status.", + }, "tests/codex-integration/codex-shim.test.ts": { warmed: false, why: diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index ab750aa06d0..c17088cb15e 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -170,7 +170,7 @@ async function completeMockCodexOAuth(options: { done: true, loggedIn: true, } as ReturnType); - const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async () => ({ status: "started" as const })); // Mirrors the login-status poll delay in login-flow.ts; other timers are intentionally dropped. const CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( @@ -4593,7 +4593,7 @@ describe("codex-auth API", () => { instructions: "Enter code: ABCD-EFGH", deviceCode: "ABCD-EFGH", })); - const openSpy = spyOn(openUrlModule, "openUrl").mockImplementation(() => {}); + const openSpy = spyOn(openUrlModule, "openUrl").mockImplementation(async () => ({ status: "started" as const })); try { const req = new Request("http://localhost/api/codex-auth/login", { method: "POST", @@ -4699,7 +4699,7 @@ describe("codex-auth API", () => { controller.onAuth({ url: "https://example.test/oauth" }); throw new Error("late failure at /home/alice/.opencodex/auth.json.ocx-tmp sk-secret-provider-key"); }; - const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async () => ({ status: "started" as const })); const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( callback: (...args: unknown[]) => void, delay?: number, @@ -4747,7 +4747,7 @@ describe("codex-auth API", () => { const { OAuthMutationBusyError } = await import("../../src/oauth/store"); const openUrlMod = await import("../../src/lib/open-url"); const originalLogin = oauth.OAUTH_PROVIDERS.chatgpt.login; - const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async () => ({ status: "started" as const })); const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( callback: (...args: unknown[]) => void, delay?: number, @@ -4834,7 +4834,7 @@ describe("codex-auth API", () => { done: false, loggedIn: false, } as ReturnType); - const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async () => ({ status: "started" as const })); const req = new Request("http://localhost/api/codex-auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -5161,7 +5161,7 @@ describe("codex-auth API", () => { loggedIn: false, error: "test-stop", } as ReturnType); - const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async () => ({ status: "started" as const })); try { const req = new Request("http://localhost/api/codex-auth/login", { diff --git a/tests/codex-integration/codex-shim-ensure-failure.test.ts b/tests/codex-integration/codex-shim-ensure-failure.test.ts new file mode 100644 index 00000000000..ab652d64305 --- /dev/null +++ b/tests/codex-integration/codex-shim-ensure-failure.test.ts @@ -0,0 +1,117 @@ +/** + * #5261: the Codex autostart shim must never hide a failed autostart, and must never be the + * reason Codex does not launch. + * + * It used to do both. Both of `ocx ensure`'s streams were discarded and its exit status ignored + * with `|| true`, so a proxy that failed to come up was completely silent while Codex launched + * against injected routing pointing at a dead port. These run the real generated script against + * a stand-in `ensure` rather than asserting on template text, because the property under test is + * what the shell does with the exit status, not what the file says. + * + * Lives beside `codex-shim.test.ts` rather than inside it: that file sits close to its line cap, + * and the cap only moves down. + */ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildUnixCodexShim } from "../../src/codex/shim"; +import { CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../../src/codex/shim-templates"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +/** The shim refuses to re-enter itself, so a nested test run must not inherit its guard state. */ +function cleanShimEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, OCX_SHIM_BYPASS: "" }; + delete env.OCX_SHIM_ACTIVE_PID; + delete env.OCX_SHIM_ACTIVE_DEPTH; + delete env.OCX_SHIM_PROBE_ACTIVE; + return env; +} + +/** + * A shim whose `ensure` is `script`. The real Codex prints a marker and exits 7, so every case + * can tell whether Codex ran and whether its exit status survived the wrapper. + */ +function shimWithEnsure(dir: string, script: string): string { + const realCodex = join(dir, "codex-real"); + writeFileSync(realCodex, "#!/bin/sh\necho codex-ran\nexit 7\n"); + chmodSync(realCodex, 0o755); + const fakeBun = join(dir, "fake-bun"); + writeFileSync(fakeBun, script); + chmodSync(fakeBun, 0o755); + const wrapper = join(dir, "codex"); + writeFileSync(wrapper, buildUnixCodexShim(realCodex, fakeBun, join(dir, "cli.ts"), "process", join(dir, "absent-token"))); + chmodSync(wrapper, 0o755); + return wrapper; +} + +function runShim(wrapper: string) { + return spawnSync("/bin/sh", [wrapper, "exec", "hello"], { + encoding: "utf8", + env: cleanShimEnv(), + timeout: INTERNAL_DEADLINE_MS, + }); +} + +describe.skipIf(process.platform === "win32")("Codex shim autostart failure (#5261)", () => { + test("a failed ensure is reported once on stderr and Codex still launches", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-ensure-fail-")); + try { + // Writes to both streams so the assertions below also prove ensure's own chatter + // stays discarded: leaking it would put noise in front of every Codex launch. + const wrapper = shimWithEnsure(dir, "#!/bin/sh\necho ensure-stdout\necho ensure-stderr >&2\nexit 1\n"); + const run = runShim(wrapper); + + expect(run.error).toBeUndefined(); + expect(run.stdout).toContain("codex-ran"); + expect(run.status).toBe(7); + expect(run.stderr).toContain(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); + expect(run.stderr).not.toContain("ensure-stderr"); + expect(run.stdout).not.toContain("ensure-stdout"); + + // Once, not once per stream or per retry. + const occurrences = run.stderr.split(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC).length - 1; + expect(occurrences).toBe(1); + } finally { + removeTreeWithRetry(dir); + } + }, SPAWN_BUDGET_MS); + + test("a successful ensure says nothing at all", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-ensure-ok-")); + try { + const wrapper = shimWithEnsure(dir, "#!/bin/sh\necho ensure-stdout\nexit 0\n"); + const run = runShim(wrapper); + + expect(run.stdout).toContain("codex-ran"); + expect(run.status).toBe(7); + expect(run.stderr).not.toContain(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); + expect(run.stdout).not.toContain("ensure-stdout"); + } finally { + removeTreeWithRetry(dir); + } + }, SPAWN_BUDGET_MS); + + test("an ensure that cannot be executed at all is still not a lockout", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-ensure-missing-")); + try { + // The launcher path does not exist. This is the headless/broken-install shape, and the + // wrapper must treat it exactly like a failed start rather than dying with it. + const realCodex = join(dir, "codex-real"); + writeFileSync(realCodex, "#!/bin/sh\necho codex-ran\nexit 7\n"); + chmodSync(realCodex, 0o755); + const wrapper = join(dir, "codex"); + writeFileSync(wrapper, buildUnixCodexShim(realCodex, join(dir, "no-such-bun"), join(dir, "cli.ts"), "process", join(dir, "absent-token"))); + chmodSync(wrapper, 0o755); + + const run = runShim(wrapper); + expect(run.stdout).toContain("codex-ran"); + expect(run.status).toBe(7); + expect(run.stderr).toContain(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); + } finally { + removeTreeWithRetry(dir); + } + }, SPAWN_BUDGET_MS); +}); diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index f684fa8de91..efc971030b4 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -8,9 +8,8 @@ import { prependPath, withInstalledShim } from "../helpers/codex-shim-install-fi import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC, SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; -const SHIM_MARKER = "opencodex codex autostart shim"; -const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; /** * A child environment with the shim's recursion-guard state stripped. @@ -1321,7 +1320,10 @@ printf '%s\\n' child-codex const driverPath = join(dir, "driver.ps1"); const realPath = join(dir, "codex-real.ps1"); writeFileSync(join(dir, "service-api-token"), "file-token\n"); - writeFileSync(ensurePath, failurePhase === "ensure" ? "throw 'fixture ensure failure'\n" : "exit 19\n"); + // The Codex phase must isolate a Codex failure, so its ensure has to SUCCEED. It used + // to exit 19, which the wrapper now correctly reports as a failed autostart (#5261), + // making both phases indistinguishable. + writeFileSync(ensurePath, failurePhase === "ensure" ? "throw 'fixture ensure failure'\n" : "exit 0\n"); writeFileSync(realPath, "throw 'fixture Codex failure'\n"); writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realPath, ensurePath, "unused.ts", "process")}`); const emptyToken = callerToken === "" ? "$env:OPENCODEX_API_AUTH_TOKEN = ''\n" : ""; @@ -1333,9 +1335,14 @@ printf '%s\\n' child-codex }); expect(result.error).toBeUndefined(); expect(result.status, result.stderr).toBe(0); + // An ensure failure no longer stops Codex from launching (#5261). The wrapper + // reports it on stderr and hands over to the real launcher, so the error that + // reaches the caller is always Codex's own, in both phases. + const surfaced = "fixture Codex failure"; expect(result.stdout.trim().split(/\r?\n/)).toEqual([ - `error:fixture ${failurePhase} failure`, `after:${callerToken ?? ""}`, "presence-preserved:True", + `error:${surfaced}`, `after:${callerToken ?? ""}`, "presence-preserved:True", ]); + expect(result.stderr.includes(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)).toBe(failurePhase === "ensure"); // A failed process must complete, rather than satisfy the check through a timeout. writeFileSync(driverPath, `\uFEFF$ErrorActionPreference = 'Stop'\n& '${wrapperPath.replace(/'/g, "''")}' exec\n`); @@ -1346,7 +1353,8 @@ printf '%s\\n' child-codex expect(uncaught.signal).toBeNull(); expect(typeof uncaught.status, uncaught.stderr).toBe("number"); expect(uncaught.status, uncaught.stderr).not.toBe(0); - expect(uncaught.stderr).toContain(`fixture ${failurePhase} failure`); + expect(uncaught.stderr).toContain(surfaced); + expect(uncaught.stderr.includes(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)).toBe(failurePhase === "ensure"); } finally { if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; diff --git a/tests/codex-integration/codex-signin-lockout.test.ts b/tests/codex-integration/codex-signin-lockout.test.ts index 14452235647..a11f6ec5789 100644 --- a/tests/codex-integration/codex-signin-lockout.test.ts +++ b/tests/codex-integration/codex-signin-lockout.test.ts @@ -11,16 +11,20 @@ * test that needed one running would be testing the wrong state. */ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { OCX_ROUTING_MARKER_LINE, OCX_SECTION_MARKER, hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl, } from "../../src/codex/injected-marker"; -import { setRootOpenaiBaseUrl, setRootRealtimeWsBaseUrl } from "../../src/codex/inject/config-toml"; +import { missingOwnedCatalogPath, setRootOpenaiBaseUrl, setRootRealtimeWsBaseUrl } from "../../src/codex/inject/config-toml"; import { stripOpencodexConfig } from "../../src/codex/inject/remove"; -import { deadProxyRoutingAdviceLines } from "../../src/cli/status"; +import { deadProxyRoutingAdviceLines, missingCodexCatalogLines } from "../../src/cli/status"; import { findCommand } from "../../src/cli/registry"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; /** A Windows catalog path as TOML stores it: a basic string doubles the separators (#1798). */ const WINDOWS_CATALOG = JSON.stringify(String.raw`C:\Users\example\.codex\opencodex-catalog.json`); @@ -129,3 +133,53 @@ describe("Codex sign-in lockout behind a stopped proxy (#5261)", () => { .toContain(`ocx ${named}`); }); }); + +describe("a Codex catalog pointer whose file is gone (#5261)", () => { + /** Builds a root config naming `catalogPath`, in the escaped form TOML actually stores. */ + const configNaming = (catalogPath: string) => + `model = "gpt-5.5"\nmodel_catalog_json = ${JSON.stringify(catalogPath)}\n`; + + test("an owned catalog that is missing is reported, and one that exists is not", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-catalog-pointer-")); + try { + // Ownership is by basename, which is what injection already uses. Pinned here so the + // detector cannot start disagreeing with the code that writes and strips the same line. + const present = join(dir, "opencodex-catalog.json"); + writeFileSync(present, "{}"); + const absent = join(dir, "gone", "opencodex-catalog.json"); + + expect(missingOwnedCatalogPath(configNaming(absent))).toBe(absent); + expect(missingOwnedCatalogPath(configNaming(present))).toBeNull(); + } finally { + removeTreeWithRetry(dir); + } + }); + + test("a catalog the user named is theirs, present or not", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-catalog-user-")); + try { + // Same missing file, a name we never write. Claiming it would put opencodex's recovery + // advice in front of a problem that is not opencodex's to explain. + const userOwned = join(dir, "gone", "my-catalog.json"); + expect(missingOwnedCatalogPath(configNaming(userOwned))).toBeNull(); + expect(missingOwnedCatalogPath('model = "gpt-5.5"\n')).toBeNull(); + } finally { + removeTreeWithRetry(dir); + } + }); + + test("the report names the file and both ways out", () => { + const lines = missingCodexCatalogLines("/somewhere/opencodex-catalog.json"); + expect(lines.length).toBeGreaterThan(0); + const joined = lines.join(" "); + expect(joined).toContain("/somewhere/opencodex-catalog.json"); + // Regenerating and removing are different outcomes; the user picks, so both are offered. + expect(joined).toContain("ocx start"); + expect(joined).toContain("ocx restore"); + expect(missingCodexCatalogLines(null)).toEqual([]); + }); + + test("the commands it names are real", () => { + for (const name of ["start", "restore"]) expect(findCommand(name)).toBeDefined(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 32ab9d258b1..0e7c7cf12d7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -394,6 +394,7 @@ "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", + "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", @@ -848,6 +849,7 @@ "oauth-login-summary.test.ts": "oauth", "oauth-manual-code.test.ts": "oauth", "oauth-open-browser-choice.test.ts": "oauth", + "oauth-open-url-result.test.ts": "oauth", "oauth-provider-reconcile.test.ts": "oauth", "oauth-public-surface.test.ts": "oauth", "oauth-reauth-bind.test.ts": "oauth", diff --git a/tests/oauth/oauth-login-open-browser.test.ts b/tests/oauth/oauth-login-open-browser.test.ts index 0b7928d2195..676641a305e 100644 --- a/tests/oauth/oauth-login-open-browser.test.ts +++ b/tests/oauth/oauth-login-open-browser.test.ts @@ -33,7 +33,7 @@ async function startLogin(flow: { url: string; instructions?: string; deviceCode const openUrlMod = await import("../../src/lib/open-url"); const opened: string[] = []; const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue(flow); - const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation((url: string) => { opened.push(url); }); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async (url: string) => { opened.push(url); return { status: "started" as const }; }); try { const req = loginRequest("kimi"); const response = await handleOauthAccountRoutes({ diff --git a/tests/oauth/oauth-open-browser-choice.test.ts b/tests/oauth/oauth-open-browser-choice.test.ts index 48c7f542788..81c133bb028 100644 --- a/tests/oauth/oauth-open-browser-choice.test.ts +++ b/tests/oauth/oauth-open-browser-choice.test.ts @@ -49,7 +49,7 @@ async function startLogin( const openUrlMod = await import("../../src/lib/open-url"); const opened: string[] = []; const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "https://accounts.x.ai/oauth/authorize?code_challenge=x" }); - const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation((url: string) => { opened.push(url); }); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async (url: string) => { opened.push(url); return { status: "started" as const }; }); try { const req = new Request("http://127.0.0.1/api/oauth/login", { method: "POST", diff --git a/tests/oauth/oauth-open-url-result.test.ts b/tests/oauth/oauth-open-url-result.test.ts new file mode 100644 index 00000000000..17ca2d97fdb --- /dev/null +++ b/tests/oauth/oauth-open-url-result.test.ts @@ -0,0 +1,55 @@ +/** + * #5261: a browser that never opened must not look like a login that is working. + * + * The launcher used to swallow its own failure and return nothing, which is what left the Codex + * login route unable to tell a browser that opened from one that never did. These cover the + * launcher itself and the recovery line it feeds; the route and the CLI block that consume the + * result are covered where those live. + * + * Nothing here opens a real browser. The started case is deliberately untested rather than + * faked: the launcher command is fixed per platform, so proving it would mean actually + * launching one on the machine running the suite. + */ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { tmpdir } from "node:os"; +import { openUrl } from "../../src/lib/open-url"; +import { BROWSER_LAUNCH_FAILED_HINT } from "../../src/cli/account-auth"; + +describe("browser launch is reported, not swallowed (#5261)", () => { + test("a URL we will not hand to a launcher is reported as such", async () => { + // Refusing a non-http scheme is the one failure that never reaches spawn, so it is also + // the one that must not silently look like a launch. + for (const url of ["", "not-a-url", "file:///etc/passwd", "javascript:alert(1)"]) { + expect(await openUrl(url)).toEqual({ status: "failed", reason: "invalid-url" }); + } + }); + + test.skipIf(process.platform !== "linux")("a launcher that cannot be resolved reports a failure", async () => { + // Linux only, and on purpose: this empties PATH so the launcher cannot be found, and on a + // developer machine with a real browser any weaker setup risks actually opening one. + const priorPath = process.env.PATH; + let emptyDir: string | undefined; + try { + emptyDir = mkdtempSync(`${tmpdir()}/ocx-empty-path-`); + process.env.PATH = emptyDir; + expect(await openUrl("http://127.0.0.1:1455/auth/callback")).toEqual({ + status: "failed", + reason: "spawn-error", + }); + } finally { + if (priorPath === undefined) delete process.env.PATH; + else process.env.PATH = priorPath; + if (emptyDir) removeTreeWithRetry(emptyDir); + } + }); + + test("the CLI hint names the manual route and the reason the port cannot move", () => { + // ChatGPT supplies the redirect URI, so the callback cannot move to a free port. That is + // the part a user cannot work out alone, which is why the hint names it and --device. + expect(BROWSER_LAUNCH_FAILED_HINT).toContain("1455"); + expect(BROWSER_LAUNCH_FAILED_HINT).toContain("--device"); + expect(BROWSER_LAUNCH_FAILED_HINT.toLowerCase()).toContain("open the url"); + }); +}); diff --git a/tests/service/autostart-health.test.ts b/tests/service/autostart-health.test.ts index 293f21f4cec..b681fa7a775 100644 --- a/tests/service/autostart-health.test.ts +++ b/tests/service/autostart-health.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { collectStartupHealth, deriveStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../../src/codex/autostart-health"; +import { collectStartupHealth, deriveStartupHealth, formatStartupRoutingDetail, injectedRoutingRestartWarningLines, startupHealthSummary } from "../../src/codex/autostart-health"; import { unusedProxyWarningLines } from "../../src/cli/status"; import { classifyCodexRouting, hasInjectedCodexRouting } from "../../src/codex/inject"; import { isCodexClientProcess, listCodexClientProcesses } from "../../src/codex/native-profile-processes"; @@ -396,6 +396,37 @@ describe("routing visibility (#2411)", () => { expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "custom-local" })).toEqual([]); expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "unknown" })).toEqual([]); }); + + // #5261: setup writes routing that outlives the session and then ends on a success line. + // The warning reuses the health model rather than re-deriving the condition, so it cannot + // disagree with what status and doctor say about the same install. + test("injectedRoutingRestartWarningLines speaks exactly when the install is restart-unsafe", () => { + const atRisk = deriveStartupHealth(base); + expect(atRisk.status).toBe("at-risk"); + const lines = injectedRoutingRestartWarningLines(atRisk); + expect(lines.length).toBeGreaterThan(0); + const joined = lines.join(" "); + expect(joined).toContain("survives a restart"); + expect(joined).toContain(startupHealthSummary(atRisk)); + // The way out that does not require the proxy to come back first. + expect(joined).toContain("ocx restore"); + + // A CLI-only shim still leaves Codex Desktop uncovered, which is the reported shape. + const shimmed = deriveStartupHealth({ ...base, shimInstalled: true, shimHealthy: true }); + expect(shimmed.status).toBe("at-risk"); + expect(injectedRoutingRestartWarningLines(shimmed).length).toBeGreaterThan(0); + // ...and the warning must stay true in that case: a healthy shim DOES restart the proxy, for + // CLI launches. Claiming nothing will would contradict the summary line printed beneath it. + expect(injectedRoutingRestartWarningLines(shimmed).join(" ")).not.toContain("nothing here will restart"); + expect(injectedRoutingRestartWarningLines(shimmed).join(" ")).toContain(startupHealthSummary(shimmed)); + + // Native routing has no opencodex restart dependency, so there is nothing to warn about. + expect(injectedRoutingRestartWarningLines(deriveStartupHealth({ ...base, routingKind: "native" }))).toEqual([]); + // Neither does a viable service, which is the state the warning is steering toward. + const served = deriveStartupHealth({ ...base, serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); + expect(served.status).toBe("protected"); + expect(injectedRoutingRestartWarningLines(served)).toEqual([]); + }); }); // #4550: configured routing is not adopted routing. A Codex client that started From 21fb6e83e11a64b8e3d224664a27b239e2c84f9e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 18:37:28 +0900 Subject: [PATCH 13/87] fix: resolve combos by what they are, and check the documented discovery limits (#5216, #5215) (#5294) * fix(gui): resolve combos by what they are, and describe failover as it runs Two user-visible strings on the compaction-routing surface described behaviour the code does not have. The panel decided whether a selection was a combo by testing for a "combo/" prefix. A combo reached through an alias carries no prefix, so it was described as an ordinary provider and none of its targets were named -- the answer existed and the operator could not see it. The panel now asks what the selection resolves to: the combo list is keyed by the public model id the server already computes, which is the alias when one is set and "combo/" otherwise, so both spellings answer the same way. It reads that list through parseComboList, the same reader the combo workspace uses, so the selector rule is not spelled out a second time here. The combo warning told the operator that a covered compaction goes to every target, including failover targets. It does not. core-combo.ts dispatches one target per loop iteration, returns as soon as one responds, and advances only after a retryable failure. An operator reading the old text would budget fan-out cost and fan-out latency for something that never happens. The warning now says the targets are attempted in order and the first that answers is used, which is both what happens and what someone debugging a slow compaction needs. Wording changed in all ten locales. The regression covers the aliased combo the prefix test could not see, and asserts the ordering sentence rather than the fan-out claim. Closes #5216 * docs: check the provider discovery limits against the registry The provider guides restate a byte ceiling and a row ceiling for thirteen fixed-host presets, in eight pages, and nothing compared any copy to the registry. #5198 fixed a preset count that had drifted across sixteen files for months for exactly that reason; these limits are the same shape one layer down. Every number is now read from that preset's modelDiscovery and asserted against every shipped guide, so lowering a ceiling fails in all eight locales at once instead of leaving seven translations describing the old one. A grouped section must first agree in the registry before one sentence may speak for two presets, which is what makes the Nscale/Vultr and Command Code sentences legitimate rather than convenient. Sections are located by brand name and the presence of a KiB or MiB token, not by a translated sentence. A restated anchor phrase is the same hand-copied value the guard exists to remove, and the brand names are Latin in all eight published locales. The byte ceiling is compared as an exact token set rather than a substring, so a stale number left beside the current one fails. The structure record claimed the guides carried identical limits. That claim was false when it was written: the Korean guide had no Featherless section, so it documented twelve of the thirteen limited presets. The section is added and the prose is replaced by a description of what is actually asserted. Closes #5215 * fix(gui,test): close three defects an adversarial pass found in this lane Combo target lookup read a plain object by the selected model id. A combo id is free-form, so an alias of "constructor" or "toString" resolved to an inherited Object member and the renderer tried to join a function. Read it with Object.hasOwn. Recognizing a combo only through the fetched list lost the canonical prefix as a signal of its own. When /api/combos has not answered yet or failed, a "combo/x" selection was described as an ordinary provider named "combo" -- worse than the alias gap this lane set out to fix, because that path is reachable whenever the management API is briefly unavailable. The prefix is kept as an independent signal and the target names fall back to the existing "its configured target providers" wording. The documentation guard compared the row ceiling as a substring of the whole paragraph, so the byte ceiling's own digits could satisfy it: a Hyperbolic paragraph saying "256 KiB and 128 raw rows" would have passed an expected 256 rows. Row numbers are now read from the prose with the unit tokens removed. All 104 locale/section combinations still pass, verified by transcribing the test's own logic over the eight guides. * docs(devlog): record lane G onboarding, update and screen improvements Why the recovery path cannot live in the dashboard, what each of the six targets needed, the differential between the two workspace pull requests with the three findings that decide their sequencing, and the one src/ defect this lane identified and left stated rather than half-fixed. * fix(gui): key the combo lookup by Map, not by a caller-configured object key A combo's public model id is free-form and operator-configured, and readComboProviders wrote it straight into an object literal. That is a prototype-pollution sink on the write side, and the read side returned an inherited member for an alias of "constructor" or "toString" -- the previous commit guarded the read with Object.hasOwn and left the write as it was. A Map removes both. There is no prototype to shadow, the guard disappears, and the failed-fetch fallback returns an empty Map rather than an empty object, so the two branches keep the same type. * fix(gui): build the combo target list in one pass React Doctor's js-flatmap-filter fired on the map().filter(Boolean) this lane introduced at CompactionRoutingPanel.tsx:51 -- one new warning in one file, and the job's blocking threshold is warning. flatMap does the same work in a single pass. The related js-combine-iterations rule is switched off in gui/doctor.config.json, but this is a different rule and is enabled, so this is a real new finding rather than an accepted one. --- .../020_lane_g.md | 120 ++++++++++++++++ .../src/content/docs/ko/guides/providers.md | 9 ++ gui/src/components/CompactionRoutingPanel.tsx | 49 ++++--- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/vi.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/tests/compaction-routing-panel.test.tsx | 52 ++++++- scripts/test-layout/layout.json | 3 +- structure/ops/docs-and-release.md | 15 +- .../docs-provider-discovery-limits.test.ts | 134 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- 18 files changed, 371 insertions(+), 34 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md create mode 100644 tests/ci-workflows/docs-provider-discovery-limits.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md b/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md new file mode 100644 index 00000000000..1d38c8531ef --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md @@ -0,0 +1,120 @@ +# Lane G — onboarding, update and screen improvements as one flow + +Status: OPEN. Branch `codex/260920-lane-g-onboarding-update`, cut from `origin/dev` +`043aa435ff`. One branch, ordered commits, one pull request against `dev`. + +Roadmap item 16 asks for connect → confirm → change → check state → recover as a single user +flow, built on the server-owned preview that landed in #5185 and #5197. Six targets were named: +#5016, #4560, #5068, #2811, #5215 and #5216. + +## The finding that shapes the flow + +The dashboard is served by the proxy. `startServer` binds the listener and that same listener +serves `gui/dist`, so when the proxy stops there is no surface left to render a recovery panel. +The state #5261 was reported in — injected routing pointing Codex's own built-in provider at a +dead loopback port — is therefore a state the dashboard cannot be part of getting out of. + +That is not a reason to leave the failure state undesigned. It moves where the design has to +land: + +- **Recovery belongs to the surfaces that survive the proxy.** #5267 already placed it there: + `ocx status` names `ocx restore` when the proxy is down and the routing is ours, the routing + marker in `config.toml` now reads `(undo: ocx restore)`, and the troubleshooting page covers + the manual edit for someone without the CLI. +- **The dashboard's job is disclosure before the fact.** It is the only surface present at the + moment the integration is applied, and it is the one that will be gone if the proxy later + stops. Naming the offline undo path at apply time is what turns a lockout into an + inconvenience. + +## What each target needed, and what this lane did + +### #5216 — compaction panel (delivered) + +Two strings described behaviour the code does not have. The panel decided combo-ness by testing +a `combo/` prefix, so a combo reached through an alias was described as an ordinary provider and +none of its targets were named. It now asks what the selection resolves to, keyed by the public +model id the server already computes, read through `parseComboList` — the same reader the combo +workspace uses, so the selector rule is not written down twice. + +The warning claimed a covered compaction goes to every target including failover targets. +`core-combo.ts` dispatches one target per loop iteration, returns as soon as one responds, and +advances only after a retryable failure. An operator reading the old text would budget fan-out +cost and latency for something that never happens. + +### #5215 — hand-copied registry values (delivered) + +Thirteen presets restate a byte ceiling and a row ceiling in eight guides, checked by nobody. +Each value is now read from that preset's `modelDiscovery`. + +Two details worth keeping: sections are located by brand name plus the presence of a `KiB`/`MiB` +token rather than by a translated phrase, because a restated anchor is the same hand-copied value +the guard exists to remove; and the byte ceiling is compared as an exact token set, so a stale +number left beside the current one fails instead of passing on a substring. + +The guard immediately earned its keep. `structure/ops/docs-and-release.md` asserted in prose that +the guides carry the same limits. That was false when it was written: the Korean guide had no +Featherless section, so it documented twelve of the thirteen limited presets. The section is +added and the prose is replaced by a description of what is actually asserted. + +### #4560 and #5068 — the two workspace pull requests (analysed, not merged) + +The instruction was to review the actual difference and consolidate only duplicated screens. They +are not the same feature and must not be treated as one. + +- 46 files and 39 files, intersecting in 31. Only **nine** of those 31 are byte-identical. +- **#4560** is the UI foundation: responsive grid, dual collapsible rails, unified filter, + Cockpit Tools import, the quota-analysis regression suite and its layout registrations. +- **#5068** is the pool follow-up: generic pool enablement and strategy persistence, strategy + preview, per-account quota refresh, remaining-token estimates calibrated from request logs, + plan badges, switch notifications, modal focus trapping. + +Three findings decide the sequencing, and none of them is "they overlap": + +1. **#5068 removes behaviour #4560 keeps.** Its `ProviderAccountCard.tsx` drops the Grok coupon + badge and the `ProviderAccountQuota` fallback. Landing #5068 after #4560 would silently + revert them. +2. **#5068 changes an email-masking decision.** `account-quota-analysis.ts` adds `rawEmail` and + prefers an unmasked value, where #4560 deliberately uses the management API's projected + email. That is a privacy boundary, not a display preference, and it needs explicit review + against the `emailMaskingEnabled` policy before either version lands. +3. **#5068 cannot land as it stands.** It folds the collapsed-sidebar CSS into + `gui/src/styles.css`, which carries a committed cap of 2,958 lines; its head is 3,186. The + ratchet only moves downward, so the remedy is the move #4560 already makes — a sibling + `sidebar-collapsed.css` — not a new number. + +Both are 28 commits behind `dev` and conflict on all ten locale catalogs through #5197, and +#4560 additionally conflicts on the two test-layout registries. Neither is a rebase this lane +could carry without absorbing the privacy decision above, so the differential is recorded here +for the coordinator to sequence rather than half-landed. + +### #5016 and #2811 — the Codex CLI update manager (not started here) + +#5016 is phase 2 of #2811 and is an open contributor pull request carrying its own plan/apply +engine. Phase 3 is the dashboard integration. Both are left to their own lane: carrying an +unlanded engine and building its surface in the same branch would put the authorization boundary +#5016 is built around under review twice. + +## Remaining scope, stated rather than closed + +The one src/ defect this lane identified and did not fix: `codexStatus` in +`src/server/management/native-integration-routes.ts` derives `state` from +`config.clientIntegrations?.codex` alone. It reports desired configuration, not what is +currently applied — it does not read `config.toml`, the routing kind, or the catalog pointer, +all of which `src/codex/injected-marker.ts` already exposes predicates for. So the dashboard +cannot answer "what is applied right now", which is half of the completion condition, and it +names no undo path at apply time, which is the disclosure the #5261 state needs. + +The bounded shape of that fix: report the applied routing state from the file rather than from +intent, carry the undo command beside it, and render both on the Codex tab. It is a change to a +DTO that every native client shares plus ten locale catalogs, so it is its own commit set rather +than an addendum to a documentation lane. + +## Verification + +Static source review plus exact-head hosted CI. Local suites, individual tests, typecheck, +build, install and live `ocx` execution were NOT RUN. + +Both delivered changes were simulated statically against the real files before commit rather +than assumed: the discovery-limit guard was run as a Python transcription of its own logic over +all eight guides, which is how the Korean gap surfaced and how the byte and row values were +confirmed to already agree with the registry everywhere else. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 9e8b372e967..cf423679604 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -345,6 +345,15 @@ discovery를 256 KiB와 raw 행 256개로 제한합니다. agent 전용 및 dedi Project ID가 포함된 URL과 dedicated deployment는 custom provider로 설정하세요. API 키는 [Scaleway console](https://console.scaleway.com/generative-api)에서 생성합니다. +**Featherless 검색:** 이 프리셋은 고정된 OpenAI 호환 host에 인증하고, 상위에서 chat과 현재 plan으로 +필터링된 인기 모델 100개만 요청합니다. 이후 registry 규칙은 각 행이 plan 사용 가능 여부, Hugging Face +gate 없음, `features.tool_use: true`를 스스로 보고하지 않으면 fail closed로 제외하고, discovery를 +128 KiB와 raw 행 100개로 제한합니다. 덕분에 수만 개 규모의 catalog를 통째로 내려받거나 캐시하지 +않습니다. `/v1/models`는 인증 없이도 호출할 수 있다고 문서화되어 있어 전달한 키가 유효한지 증명하지 +못합니다. chat 요청에는 설정된 Bearer key를 그대로 사용합니다. Featherless 약관은 개인 plan을 대화형 +및 프로토타이핑 용도로 제한하며, 임의의 애플리케이션에는 Scale plan이 필요합니다. 키는 +[Featherless dashboard](https://featherless.ai/account/api-keys)에서 생성합니다. + **Novita 검색:** 키 기반 프리셋은 `openai-chat` adapter를 사용하며 Bearer key를 Novita의 고정 OpenAI 호환 host에만 보냅니다. 공개 model list에서 `model_type: chat`과 `chat/completions` endpoint를 모두 보고하는 행만 유지하고 discovery를 512 KiB와 raw 256행으로 제한합니다. catalog가 공개되어 있으므로 diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx index e2285891447..6875b7ab3fa 100644 --- a/gui/src/components/CompactionRoutingPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -4,6 +4,7 @@ import { IconAlert } from "../icons"; import { Select } from "../ui"; import { createBoundedFetch } from "../bounded-fetch"; import { requireJson, type ModelInfo } from "../pages/dashboard-shared"; +import { comboModelId, parseComboList } from "../combo-workspace-data"; import { formatNamespacedModelId } from "../provider-icons"; type Setting = { model: string; reasoningEffort?: string; triggers?: string[] } | null; @@ -32,17 +33,22 @@ function choiceToTriggers(choice: string): string[] | undefined { return undefined; } -function readComboProviders(payload: unknown): Record { - const combos = (payload as { combos?: unknown })?.combos; - if (!Array.isArray(combos)) return {}; - const result: Record = {}; - for (const combo of combos) { - if (!combo || typeof combo !== "object" || typeof (combo as { id?: unknown }).id !== "string") continue; - const targets = (combo as { targets?: unknown }).targets; - const providers = Array.isArray(targets) - ? targets.map(target => (target as { provider?: unknown })?.provider).filter((value): value is string => typeof value === "string") - : []; - result[(combo as { id: string }).id] = [...new Set(providers)]; +/** + * Target providers keyed by the selector a client actually requests. + * + * Keyed by the combo's public model id rather than its raw id, because a combo reached through + * an alias carries no `combo/` prefix: the panel used to test for that prefix, fail to + * recognize an aliased combo, and describe it as an ordinary provider while naming none of its + * targets (#5216). `parseComboList` is the same reader the combo workspace uses, so the + * selector rule lives in one place instead of being spelled out again here. + */ +function readComboProviders(payload: unknown): Map { + // A Map, not an object: the key is a combo's public model id, which is caller-configured and + // free-form. Writing that into an object literal is a prototype-pollution sink, and reading it + // back would return an inherited member for an alias of `constructor` or `toString`. + const result = new Map(); + for (const combo of parseComboList(payload)) { + result.set(combo.model, [...new Set(combo.targets.flatMap(target => target.provider ? [target.provider] : []))]); } return result; } @@ -79,7 +85,7 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model const [busy, setBusy] = useState(false); const [loadError, setLoadError] = useState(false); const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); - const [comboProviders, setComboProviders] = useState>({}); + const [comboProviders, setComboProviders] = useState>(() => new Map()); const active = useRef(false); const pending = useRef | null>(null); @@ -99,7 +105,8 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model const response = await fetch(`${apiBase}/api/settings`, { signal: request.signal }); const value = readSetting(await requireJson(response)); if (active.current && pending.current === request) accept(value); - const combos = await fetch(`${apiBase}/api/combos`, { signal: request.signal }).then(requireJson).then(readComboProviders).catch(() => ({})); + const combos = await fetch(`${apiBase}/api/combos`, { signal: request.signal }) + .then(requireJson).then(readComboProviders).catch(() => new Map()); if (active.current && pending.current === request) setComboProviders(combos); } catch { if (active.current && pending.current === request) setLoadError(true); @@ -163,10 +170,16 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model const dirty = model !== (saved?.model ?? "") || effort !== (saved?.reasoningEffort ?? "") || triggers !== triggersToChoice(saved?.triggers); + // Ask what the selection resolves to instead of reading its name. An aliased combo answers + // here exactly like a prefixed one (#5216). + const comboTargets = comboProviders.get(model); + // The canonical prefix stays a combo signal of its own. It is the only one left when + // /api/combos has not answered yet or failed, and losing it there would describe a combo as + // an ordinary provider named "combo" — worse than the alias gap this fixes. + const isCombo = comboTargets !== undefined || model.startsWith(comboModelId("")); const namespace = model.slice(0, Math.max(model.indexOf("/"), 0)); - const combo = namespace === "combo" ? model.slice(namespace.length + 1) : ""; - const provider = namespace && !combo ? namespace : model; - const providers = comboProviders[combo]?.join(", ") || t("compactionRouting.comboProvidersUnknown"); + const provider = isCombo ? "" : (namespace || model); + const providers = comboTargets?.join(", ") || t("compactionRouting.comboProvidersUnknown"); const routesAutomatic = triggers !== "manual"; return ( @@ -195,10 +208,10 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
- {provider &&
{combo + {model &&
{isCombo ? t("compactionRouting.comboWarning", { combo: model, providers }) : t("compactionRouting.providerWarning", { provider })}
} - {provider && routesAutomatic &&
{t("compactionRouting.autoNotice")}
} + {model && routesAutomatic &&
{t("compactionRouting.autoNotice")}
} {loadError &&
{t("compactionRouting.loadFailed")}
} {feedback === "failed" &&
{t("compactionRouting.saveFailed")}
} {feedback === "saved" &&
{t("compactionRouting.saved")}
} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1712170d5bf..bb936060c12 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -366,7 +366,7 @@ export const de: Record = { "compactionRouting.dataNotice": "Eine abgedeckte Komprimierungsanfrage sendet das gesamte Gespräch zur Zusammenfassung an den Anbieter des gewählten Modells, auch wenn das Gespräch bei einem anderen Anbieter läuft.", "compactionRouting.autoNotice": "Automatische Komprimierung läuft von selbst, daher kann ein langes Gespräch an diesen Anbieter gehen, ohne dass du es angefordert hast.", "compactionRouting.providerWarning": "Mit dieser Einstellung sendet jede abgedeckte Komprimierungsanfrage den vollständigen Gesprächsinhalt zur Zusammenfassung an {provider}.", - "compactionRouting.comboWarning": "Mit dieser Einstellung sendet jede abgedeckte Komprimierungsanfrage den vollständigen Gesprächsinhalt zur Zusammenfassung an jedes Ziel der Combo {combo} ({providers}), einschließlich Failover-Zielen.", + "compactionRouting.comboWarning": "Mit dieser Einstellung sendet jede abgedeckte Komprimierungsanfrage den vollständigen Gesprächsinhalt zur Zusammenfassung an die Combo {combo}. Die Combo versucht ihre Ziele ({providers}) der Reihe nach und verwendet das erste, das antwortet, sodass jedes davon das Gespräch erhalten kann.", "compactionRouting.comboProvidersUnknown": "ihre konfigurierten Zielanbieter", "compactionRouting.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.", "compactionRouting.saved": "Komprimierungseinstellungen gespeichert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ab3a7dc8d6b..93af425073b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -384,7 +384,7 @@ export const en = { "compactionRouting.dataNotice": "A covered compaction request sends the entire conversation to the selected model's provider for summarization, even when the conversation runs on another provider.", "compactionRouting.autoNotice": "Automatic compaction runs on its own, so a long conversation can be sent to that provider without you asking for it.", "compactionRouting.providerWarning": "With this setting, every covered compaction request sends the full conversation contents to {provider} for summarization.", - "compactionRouting.comboWarning": "With this setting, every covered compaction request sends the full conversation contents to every target of combo {combo} ({providers}), including failover targets, for summarization.", + "compactionRouting.comboWarning": "With this setting, every covered compaction request sends the full conversation contents to combo {combo} for summarization. The combo attempts its targets ({providers}) in order and uses the first that answers, so any one of them can receive the conversation.", "compactionRouting.comboProvidersUnknown": "its configured target providers", "compactionRouting.loadFailed": "Could not load compaction settings.", "compactionRouting.saved": "Compaction settings saved.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1cc6e4e5265..b81652bacd2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -374,7 +374,7 @@ export const fr: Record = { "compactionRouting.dataNotice": "Une requête de compaction couverte envoie toute la conversation au fournisseur du modèle choisi pour la résumer, même si la conversation s’exécute chez un autre fournisseur.", "compactionRouting.autoNotice": "La compaction automatique se déclenche d’elle-même : une longue conversation peut donc partir chez ce fournisseur sans que vous l’ayez demandé.", "compactionRouting.providerWarning": "Avec ce réglage, chaque requête de compaction couverte envoie l’intégralité du contenu de la conversation à {provider} pour la résumer.", - "compactionRouting.comboWarning": "Avec ce réglage, chaque requête de compaction couverte envoie l’intégralité du contenu de la conversation à chaque cible du combo {combo} ({providers}), y compris les cibles de bascule, pour le résumer.", + "compactionRouting.comboWarning": "Avec ce réglage, chaque requête de compaction couverte envoie l’intégralité du contenu de la conversation au combo {combo} pour le résumer. Le combo essaie ses cibles ({providers}) dans l’ordre et retient la première qui répond, donc n’importe laquelle peut recevoir la conversation.", "compactionRouting.comboProvidersUnknown": "ses fournisseurs cibles configurés", "compactionRouting.loadFailed": "Impossible de charger les paramètres de compaction.", "compactionRouting.saved": "Paramètres de compaction enregistrés.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index b61caac4a61..7565f3c2d9a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -375,7 +375,7 @@ export const ja: Record = { "compactionRouting.dataNotice": "適用対象の圧縮リクエストは、会話が別のプロバイダーで動いていても、会話全体を選択したモデルのプロバイダーへ送信して要約します。", "compactionRouting.autoNotice": "自動圧縮はユーザーが求めなくても実行されるため、長い会話が知らないうちにそのプロバイダーへ送信されることがあります。", "compactionRouting.providerWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のために {provider} へ送信されます。", - "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が、フェイルオーバー先を含むコンボ {combo} のすべてのターゲット({providers})へ要約のために送信されます。", + "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のためコンボ {combo} へ送信されます。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、そのいずれもが会話を受け取る可能性があります。", "compactionRouting.comboProvidersUnknown": "設定済みのターゲットプロバイダー", "compactionRouting.loadFailed": "圧縮設定を読み込めませんでした。", "compactionRouting.saved": "圧縮設定を保存しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 68b1a561a65..94bd6fcab74 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -370,7 +370,7 @@ export const ko: Record = { "compactionRouting.dataNotice": "적용 대상 압축 요청은 대화가 다른 프로바이더에서 실행 중이더라도 전체 대화를 선택한 모델의 프로바이더로 보내 요약합니다.", "compactionRouting.autoNotice": "자동 압축은 사용자가 요청하지 않아도 실행되므로, 긴 대화가 예고 없이 해당 프로바이더로 전송될 수 있습니다.", "compactionRouting.providerWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 {provider}로 전송됩니다.", - "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 장애 조치 대상을 포함한 콤보 {combo}의 모든 대상({providers})으로 요약을 위해 전송됩니다.", + "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 콤보 {combo}로 전송됩니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 그중 어느 것이든 대화를 받을 수 있습니다.", "compactionRouting.comboProvidersUnknown": "구성된 대상 프로바이더", "compactionRouting.loadFailed": "압축 설정을 불러올 수 없습니다.", "compactionRouting.saved": "압축 설정을 저장했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 440958324df..bf7c4fcd783 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -375,7 +375,7 @@ export const ru: Record = { "compactionRouting.dataNotice": "Охваченный запрос сжатия отправляет весь разговор провайдеру выбранной модели для составления сводки, даже если разговор идёт у другого провайдера.", "compactionRouting.autoNotice": "Автоматическое сжатие запускается само, поэтому длинный разговор может уйти этому провайдеру без вашего запроса.", "compactionRouting.providerWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", - "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора каждой цели комбо {combo} ({providers}), включая резервные цели, для составления сводки.", + "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо пробует свои цели ({providers}) по порядку и использует первую ответившую, поэтому разговор может получить любая из них.", "compactionRouting.comboProvidersUnknown": "его настроенные целевые провайдеры", "compactionRouting.loadFailed": "Не удалось загрузить настройки сжатия.", "compactionRouting.saved": "Настройки сжатия сохранены.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0707038237e..eb989bb3ba2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -376,7 +376,7 @@ export const tr: Record = { "compactionRouting.dataNotice": "Kapsama giren bir özetleme isteği, konuşma başka bir sağlayıcıda yürütülse bile konuşmanın tamamını özetlenmek üzere seçilen modelin sağlayıcısına gönderir.", "compactionRouting.autoNotice": "Otomatik özetleme kendiliğinden çalışır; bu nedenle uzun bir konuşma siz istemeden o sağlayıcıya gidebilir.", "compactionRouting.providerWarning": "Bu ayarla kapsama giren her özetleme isteği, konuşmanın tüm içeriğini özetlenmek üzere {provider} sağlayıcısına gönderir.", - "compactionRouting.comboWarning": "Bu ayarla kapsama giren her özetleme isteği, konuşmanın tüm içeriğini yedek hedefler dahil {combo} kombosunun her hedefine ({providers}) özetlenmek üzere gönderir.", + "compactionRouting.comboWarning": "Bu ayarla kapsama giren her özetleme isteği, konuşmanın tüm içeriğini özetlenmek üzere {combo} kombosuna gönderir. Kombo hedeflerini ({providers}) sırayla dener ve yanıt veren ilkini kullanır; dolayısıyla bunlardan herhangi biri konuşmayı alabilir.", "compactionRouting.comboProvidersUnknown": "yapılandırılmış hedef sağlayıcıları", "compactionRouting.loadFailed": "Özetleme ayarları yüklenemedi.", "compactionRouting.saved": "Özetleme ayarları kaydedildi.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 9af7198a8be..d5b6a149f53 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -368,7 +368,7 @@ export const vi: Record = { "compactionRouting.dataNotice": "Yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ cuộc trò chuyện đến nhà cung cấp của model đã chọn để tóm tắt, ngay cả khi cuộc trò chuyện đang chạy ở nhà cung cấp khác.", "compactionRouting.autoNotice": "Nén tự động tự chạy, nên một cuộc trò chuyện dài có thể được gửi đến nhà cung cấp đó mà bạn không yêu cầu.", "compactionRouting.providerWarning": "Với thiết lập này, mỗi yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ nội dung cuộc trò chuyện đến {provider} để tóm tắt.", - "compactionRouting.comboWarning": "Với thiết lập này, mỗi yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ nội dung cuộc trò chuyện đến mọi đích của combo {combo} ({providers}), bao gồm cả đích dự phòng, để tóm tắt.", + "compactionRouting.comboWarning": "Với thiết lập này, mỗi yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ nội dung cuộc trò chuyện đến combo {combo} để tóm tắt. Combo thử lần lượt các đích ({providers}) và dùng đích trả lời đầu tiên, nên bất kỳ đích nào cũng có thể nhận cuộc trò chuyện.", "compactionRouting.comboProvidersUnknown": "các nhà cung cấp đích đã cấu hình của nó", "compactionRouting.loadFailed": "Không thể tải cài đặt nén.", "compactionRouting.saved": "Đã lưu cài đặt nén.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 01799d6b8b1..fd1f07dd55e 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -265,7 +265,7 @@ export const zhTW: Record = { "compactionRouting.dataNotice": "涵蓋的壓縮請求會將整個對話傳送給所選模型的供應商進行摘要,即使對話正在其他供應商上執行。", "compactionRouting.autoNotice": "自動壓縮會自行觸發,因此長對話可能在你沒有主動要求的情況下被傳送給該供應商。", "compactionRouting.providerWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給 {provider} 進行摘要。", - "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 的每個目標({providers}),包括容錯移轉目標,以進行摘要。", + "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 以進行摘要。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到該對話。", "compactionRouting.comboProvidersUnknown": "其已設定的目標供應商", "compactionRouting.loadFailed": "無法載入壓縮設定。", "compactionRouting.saved": "壓縮設定已儲存。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0f0e06ca155..3189179e3fe 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -370,7 +370,7 @@ export const zh: Record = { "compactionRouting.dataNotice": "被覆盖的压缩请求会将整个对话发送给所选模型的提供商进行摘要,即使对话正在其他提供商上运行。", "compactionRouting.autoNotice": "自动压缩会自行触发,因此长对话可能在你没有主动要求的情况下被发送给该提供商。", "compactionRouting.providerWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给 {provider} 进行摘要。", - "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 的每个目标({providers}),包括故障转移目标,以进行摘要。", + "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 以进行摘要。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到该对话。", "compactionRouting.comboProvidersUnknown": "其已配置的目标提供商", "compactionRouting.loadFailed": "无法加载压缩设置。", "compactionRouting.saved": "压缩设置已保存。", diff --git a/gui/tests/compaction-routing-panel.test.tsx b/gui/tests/compaction-routing-panel.test.tsx index 9e698c02bb6..13fb2cde8d0 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -14,8 +14,12 @@ let container: HTMLDivElement; let setting: { model: string; reasoningEffort?: string; triggers?: string[] } | null; let failLoad: boolean; let failSave: boolean; +let combosUnavailable: boolean; let writes: unknown[]; const models = [{ id: "cheap", provider: "gateway", namespaced: "gateway/cheap" }, { id: "compact", provider: "combo", namespaced: "combo/compact" }]; +// A combo reached through an alias carries no `combo/` prefix, which is the shape #5216 was +// filed about: the panel has to learn what the selection resolves to, not read its name. +const ALIASED_COMBO = { id: "fast", model: "quickpick", alias: "quickpick", targets: [{ provider: "xai", model: "a" }, { provider: "gateway", model: "b" }] }; beforeEach(() => { previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); @@ -25,10 +29,14 @@ beforeEach(() => { } Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); win.localStorage.setItem("ocx-lang", "en"); - setting = null; failLoad = false; failSave = false; writes = []; + setting = null; failLoad = false; failSave = false; combosUnavailable = false; writes = []; Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (_input: unknown, init?: RequestInit) => { if (String(_input).endsWith("/api/combos")) { - return Response.json({ combos: [{ id: "compact", model: "combo/compact", targets: [{ provider: "gateway", model: "a" }, { provider: "openai-apikey", model: "b" }, { provider: "gateway", model: "c" }] }] }); + if (combosUnavailable) return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json({ combos: [ + { id: "compact", model: "combo/compact", targets: [{ provider: "gateway", model: "a" }, { provider: "openai-apikey", model: "b" }, { provider: "gateway", model: "c" }] }, + ALIASED_COMBO, + ] }); } if (init?.method === "PUT") { const body = JSON.parse(String(init.body)); @@ -148,7 +156,45 @@ test("discloses that the selected provider receives the full conversation", asyn await choose("model", "gateway/cheap"); expect(container.querySelector('[role="note"]')?.textContent).toContain("sends the full conversation contents to gateway for summarization"); await choose("model", "combo/compact"); - expect(container.querySelector('[role="note"]')?.textContent).toContain("every target of combo combo/compact (gateway, openai-apikey), including failover targets"); + const comboNote = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(comboNote).toContain("combo combo/compact"); + expect(comboNote).toContain("(gateway, openai-apikey)"); + // The runtime tries one target at a time and stops at the first answer; the panel used to + // promise fan-out to every target, which an operator would budget latency and cost for. + expect(comboNote).toContain("in order and uses the first that answers"); + expect(comboNote).not.toContain("every target"); await choose("model", "Use conversation model"); expect(container.querySelector('[role="note"]')).toBeNull(); }); + +test("names the targets of a combo reached through an alias", async () => { + setting = { model: ALIASED_COMBO.model }; + await render(); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + // Before #5216 this selection had no `combo/` prefix, so the panel called it a provider and + // named none of its targets. + expect(note).toContain(`combo ${ALIASED_COMBO.model}`); + expect(note).toContain("(xai, gateway)"); + expect(note).not.toContain("its configured target providers"); + expect(note).toContain("in order and uses the first that answers"); +}); + +test("still calls a prefixed combo a combo when the combo list is unavailable", async () => { + // The combo list is the only source of target names, and losing it must not downgrade the + // disclosure to "the provider named combo receives your conversation". + combosUnavailable = true; + setting = { model: "combo/compact" }; + await render(); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(note).toContain("combo combo/compact"); + expect(note).toContain("its configured target providers"); +}); + +test("an alias that shadows an Object member is not read as a target list", async () => { + // A combo id is free-form, so `constructor` is a legal alias. Reading it off a plain object + // would hand the renderer a function to join. + setting = { model: "constructor" }; + await render(); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(note).toContain("sends the full conversation contents to constructor for summarization"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2a5d06246c3..80f0a028142 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1582,7 +1582,8 @@ "openai-chat-developer-position.test.ts": "adapters/openai", "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", - "api-key-model-scope.test.ts": "server" + "api-key-model-scope.test.ts": "server", + "docs-provider-discovery-limits.test.ts": "ci-workflows" }, "migrated": [ "adapters", diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 89fe8db595a..6093bcc7961 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -48,7 +48,7 @@ sidebar and either add localized copies or intentionally accept Starlight fallba Provider preset totals are recounted from the current registry when a preset lands. The documented split is 95 total: 79 key-based, 12 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight -quickstarts carry the same counts, and the guides carry the same fixed-host discovery limits. +quickstarts carry the same counts. That recount is no longer a manual obligation. Seventeen places restate these numbers and sixteen of them drifted once already — the English guide reached 95 while every translation and every @@ -58,6 +58,19 @@ key-based split from `PROVIDER_REGISTRY` and asserts them against each page, so fails every locale at once instead of drifting. Each page is located by a locale-specific phrase rather than by its number, so rewording a sentence fails the check and asks to be re-anchored. +The fixed-host discovery limits are the same shape one layer down, and this document used to +assert their parity in prose: it claimed the guides carried the same limits, across sixteen-plus +files, verified by nobody. That claim was false when it was written — the Korean guide had no +Featherless section at all, so it documented twelve of the thirteen limited presets. +`tests/ci-workflows/docs-provider-discovery-limits.test.ts` replaces the claim with the check: +each section's byte and row ceilings are read from that preset's `modelDiscovery` and asserted +against every shipped guide, and a grouped section must first agree in the registry before one +sentence may describe both presets. Sections are located by brand name and the presence of a +`KiB`/`MiB` token rather than by a translated phrase, because a restated anchor is the same +hand-copied value the guard exists to remove; a section that is missing or duplicated fails by +name. The byte ceiling is compared as an exact token set, so a stale number left beside the +current one fails instead of passing on a substring. + Native retirement keeps active model/quota instructions aligned across locales with the [catalog contract](../catalog.md#shared-catalog). Historical records and other providers sharing a model-name fragment remain distinct from current Codex-native support. diff --git a/tests/ci-workflows/docs-provider-discovery-limits.test.ts b/tests/ci-workflows/docs-provider-discovery-limits.test.ts new file mode 100644 index 00000000000..ab160372431 --- /dev/null +++ b/tests/ci-workflows/docs-provider-discovery-limits.test.ts @@ -0,0 +1,134 @@ +/** + * The fixed-host discovery limits in the provider guides must be read back from the registry. + * + * #5198 corrected a preset count that had drifted across sixteen files, and the interesting part + * was how long it survived: nothing compared any copy to the registry, so CI stayed green the + * whole time. The per-provider discovery limits are the same shape one layer down -- a byte + * ceiling and a row ceiling, restated by hand in eight pages, checked by nobody (#5215). + * + * Every number here is derived from PROVIDER_REGISTRY, so lowering a ceiling fails in every + * locale at once instead of leaving seven translations describing the old one. + * + * Sections are located by the brand name and the presence of a unit token, not by a translated + * sentence. A restated anchor phrase is the same hand-copied value this test exists to remove, + * and the brand names are Latin in all eight published locales. A page where the section is + * missing or appears twice fails by name and asks to be re-anchored; that is how the Korean + * guide's absent Featherless section was found. + */ +import { describe, expect, test } from "bun:test"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { repoPath } from "../helpers/repo-root"; + +/** English plus every translated guide that ships, matching docs-site/astro.config.mjs. */ +const GUIDES = [ + { locale: "en", path: "docs-site/src/content/docs/guides/providers.md" }, + { locale: "fr", path: "docs-site/src/content/docs/fr/guides/providers.md" }, + { locale: "ja", path: "docs-site/src/content/docs/ja/guides/providers.md" }, + { locale: "ko", path: "docs-site/src/content/docs/ko/guides/providers.md" }, + { locale: "ru", path: "docs-site/src/content/docs/ru/guides/providers.md" }, + { locale: "tr", path: "docs-site/src/content/docs/tr/guides/providers.md" }, + { locale: "zh-cn", path: "docs-site/src/content/docs/zh-cn/guides/providers.md" }, + { locale: "zh-tw", path: "docs-site/src/content/docs/zh-tw/guides/providers.md" }, +] as const; + +/** + * One documented section per entry. `ids` holds every registry preset the section speaks for; + * a grouped section must agree in the registry before one sentence can describe both. + */ +const SECTIONS = [ + { brand: "Chutes", ids: ["chutes"] }, + { brand: "DeepInfra", ids: ["deepinfra"] }, + { brand: "Hyperbolic", ids: ["hyperbolic"] }, + { brand: "Nscale", ids: ["nscale", "vultr"] }, + { brand: "Command Code", ids: ["command-code", "commandcode"] }, + { brand: "SambaNova", ids: ["sambanova"] }, + { brand: "Nebius", ids: ["nebius"] }, + { brand: "Crusoe", ids: ["crusoe"] }, + { brand: "DigitalOcean", ids: ["digitalocean"] }, + { brand: "Scaleway", ids: ["scaleway"] }, + { brand: "Featherless", ids: ["featherless"] }, + { brand: "Novita", ids: ["novita"] }, + { brand: "Baseten", ids: ["baseten"] }, +] as const; + +const UNIT_TOKEN = /(\d+(?:[.,]\d+)?)\s*(KiB|MiB)/g; + +interface Limits { bytes: number; rows: number } + +function registryLimits(id: string): Limits { + const entry = PROVIDER_REGISTRY.find(row => row.id === id); + expect(entry, `no registry preset with id "${id}"; re-anchor this check`).toBeDefined(); + const discovery = entry!.modelDiscovery; + expect(discovery, `preset "${id}" declares no modelDiscovery`).toBeDefined(); + const { maxResponseBytes, maxModels } = discovery!; + expect(typeof maxResponseBytes, `preset "${id}" declares no maxResponseBytes`).toBe("number"); + expect(typeof maxModels, `preset "${id}" declares no maxModels`).toBe("number"); + return { bytes: maxResponseBytes!, rows: maxModels! }; +} + +/** The documented spelling of a byte ceiling: whole MiB where it divides, otherwise KiB. */ +function unitLabel(bytes: number): string { + const MIB = 1024 * 1024; + return bytes % MIB === 0 ? `${bytes / MIB} MiB` : `${bytes / 1024} KiB`; +} + +/** The paragraph that states this section's limits, located by brand plus a unit token. */ +async function limitParagraph(path: string, brand: string): Promise { + const source = await Bun.file(repoPath(path)).text(); + const paragraphs = source.split(/\n\s*\n/).filter(block => { + UNIT_TOKEN.lastIndex = 0; + return block.includes(brand) && UNIT_TOKEN.test(block); + }); + expect( + paragraphs.length, + `${path} should state the ${brand} discovery limits in exactly one paragraph; found ${paragraphs.length}`, + ).toBe(1); + return paragraphs[0]!; +} + +function unitTokens(paragraph: string): string[] { + UNIT_TOKEN.lastIndex = 0; + return [...paragraph.matchAll(UNIT_TOKEN)].map(match => `${match[1]} ${match[2]}`); +} + +/** Every standalone integer left once the byte ceilings are removed. */ +function rowNumbers(paragraph: string): number[] { + UNIT_TOKEN.lastIndex = 0; + const withoutUnits = paragraph.replace(UNIT_TOKEN, " "); + return [...withoutUnits.matchAll(/(? Number(match[1])); +} + +describe("documented provider discovery limits match the registry", () => { + test("the registry is the only source of the numbers under test", () => { + expect(SECTIONS.length).toBeGreaterThan(0); + for (const section of SECTIONS) { + const limits = section.ids.map(registryLimits); + expect(limits[0]!.bytes, `${section.brand} byte ceiling`).toBeGreaterThan(0); + expect(limits[0]!.rows, `${section.brand} row ceiling`).toBeGreaterThan(0); + // A grouped section states one pair for several presets. If they ever diverge, the + // sentence is wrong for one of them and the group has to be split, not re-measured. + for (const other of limits.slice(1)) { + expect(other, `${section.ids.join(" and ")} must declare the same discovery limits`) + .toEqual(limits[0]!); + } + } + }); + + for (const guide of GUIDES) { + for (const section of SECTIONS) { + const { bytes, rows } = registryLimits(section.ids[0]!); + test(`${guide.locale} guide states ${unitLabel(bytes)} and ${rows} rows for ${section.brand}`, async () => { + const paragraph = await limitParagraph(guide.path, section.brand); + // An exact token set, not a substring: a stale ceiling left beside the current one + // would otherwise pass while still telling the reader the wrong number. + expect(unitTokens(paragraph), `${guide.path} ${section.brand} byte ceiling`) + .toEqual([unitLabel(bytes)]); + // Read the row ceiling from the prose with the unit tokens removed. Left in, the byte + // ceiling's own digits satisfy the check: a Hyperbolic paragraph saying "256 KiB and + // 128 raw rows" would pass an expected 256 rows on the strength of the byte number. + expect(rowNumbers(paragraph), `${guide.path} ${section.brand} row ceiling`) + .toContain(rows); + }); + } + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0e7c7cf12d7..bcc3905de72 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1414,5 +1414,6 @@ "openai-chat-developer-position.test.ts": "adapters/openai", "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", - "api-key-model-scope.test.ts": "server" + "api-key-model-scope.test.ts": "server", + "docs-provider-discovery-limits.test.ts": "ci-workflows" } From 555f0cacdf2a6983299de0e15bc9ea4243c31ff4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 18:41:30 +0900 Subject: [PATCH 14/87] Make the logs page, the ledger and the exporter agree (#5300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(usage): one terminal classification for a finished request Three surfaces answered "how did this request end" three different ways. The durable row carries terminalStatus and closeReason, the Prometheus exporter had its own private classifyResult, and the dashboard read the numeric HTTP status and nothing else. That is not cosmetic. A turn cut short by max_output_tokens is durably status 200 with terminalStatus "incomplete", which the exporter reports as incomplete and the dashboard rendered as a green 200: the metric and the operator disagreed about whether the user got an answer. Move the classifier into src/usage/request-outcome.ts and have the exporter import it, including its result label set, so the four strings are stated once. Semantic terminal facts are read before the numeric status, which is the whole point; the status is consulted only when no terminal event was recorded. The module also names the send totals a surface should show, because reporting sends without the unresolved remainder is how a duplicate-send incident stays invisible. It is a leaf: its only import is a type. * fix(gui): make the logs page agree with the ledger and the exporter Carries the rehydration half of #2366 — the half that brings the durable terminal facts out to where an operator reads them. Its separate attribution vocabulary is deliberately left behind, because the landed stage and cause model already owns that question and two vocabularies for one thing is the class of defect this batch exists to remove. The page classified every request by its numeric HTTP status alone and showed no send count at all, so it disagreed with both other surfaces about the same request. A turn cut short by max_output_tokens is durably incomplete and is reported incomplete by the exporter; the page rendered a green 200. The data was never missing — /api/logs spreads the whole durable entry — the page simply did not declare terminalStatus, closeReason or spend. It now declares them and calls the shared classifier rather than reimplementing the precedence, so agreement is structural instead of a rule someone maintains. It also shows the upstream send count, and names the unresolved remainder when there is one, because a send total without it is how a duplicate-send incident stays invisible. The recovery-kind union is now the durable roster instead of a copy. The copy had drifted to nine of thirteen members, so key-401, oauth-account-429, opaque-blob-rejection and reasoning-effort-downgrade each reached the operator as "Unknown recovery reason" — four real causes rendered as an absence of one. The satisfies clause makes the next added kind a typecheck failure here rather than a silent fallback, and the four missing labels are added across all ten catalogs. Co-authored-by: chilung * test(usage): hold the three surfaces to one answer The exporter is driven over the full cross product of status, terminal status and close reason and its emitted result label is compared against the shared classifier, so the two cannot drift apart without a case objecting. The cases that actually broke are asserted by name as well: an incomplete 200 is not a success, and a cancelled 200 is aborted. A source oracle holds the dashboard to the same contract. It has to call the shared classifier rather than read the status, it has to show the send total and the unresolved remainder, and its recovery-label map has to cover every member of the durable roster. That last one is a source oracle rather than a type check because the page is compiled by a separate project, which is how the copy drifted to nine of thirteen members unnoticed in the first place. Every label key the page names is required to exist in all ten catalogs, so a new recovery kind cannot ship with an English label and nine blanks. One case asserts the exporter's whole label set is still protocol, result, recovery and le after thirty-two requests carrying recoveries, which is the bounded-cardinality promise stated as an assertion rather than a convention. * docs(devlog): record lane C2 and refresh the deferred dispositions Each item that did not land carries the reason that is true against current dev, not the one written a day ago. #3748's blocker is now narrower and more useful than "parallel store": the recorder does not yet record why a request finally failed, so there is nothing closed to group by. #3983's emission path turns out not to be ephemeral, because stderr is redirected to the service log under both launchd and systemd. #5063 has a concurrent-append data-loss window that the rename cannot see. Retention and masking are stated in one table rather than reimplemented, with the policy that projections inherit both instead of getting their own. --------- Co-authored-by: chilung --- .../030_lane_c2.md | 220 ++++++++++++++++++ gui/src/i18n/de.ts | 11 + gui/src/i18n/en.ts | 11 + gui/src/i18n/fr.ts | 11 + gui/src/i18n/ja.ts | 11 + gui/src/i18n/ko.ts | 11 + gui/src/i18n/ru.ts | 11 + gui/src/i18n/tr.ts | 11 + gui/src/i18n/vi.ts | 11 + gui/src/i18n/zh-TW.ts | 11 + gui/src/i18n/zh.ts | 11 + gui/src/pages/Logs.tsx | 72 ++++-- scripts/test-layout/layout.json | 1 + src/lib/request-failure-model.ts | 2 +- src/server/request-metrics.ts | 28 ++- src/usage/log.ts | 79 ++----- src/usage/request-outcome.ts | 102 ++++++++ src/usage/telemetry-contract.ts | 83 +++++++ tests/fixtures/test-layout-expected.json | 1 + tests/usage/request-outcome-agreement.test.ts | 219 +++++++++++++++++ 20 files changed, 820 insertions(+), 97 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/030_lane_c2.md create mode 100644 src/usage/request-outcome.ts create mode 100644 src/usage/telemetry-contract.ts create mode 100644 tests/usage/request-outcome-agreement.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/030_lane_c2.md b/devlog/_plan/260920_meaning_preservation_batch/030_lane_c2.md new file mode 100644 index 00000000000..c07447cf53f --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/030_lane_c2.md @@ -0,0 +1,220 @@ +# Lane C2 — telemetry projections on the landed vocabulary + +Status: OPEN. Branch `codex/260920-lane-c2-telemetry-projections`, cut from `dev` at +`043aa435ff8f86095f55cbe08f74d45b9858da59`, which is where lane C's stage, cause and resend +vocabulary landed ([020_lane_c.md](020_lane_c.md)). One branch, ordered commits, one pull request +to `dev`. + +## What this lane fixes + +The completion condition for bundle 14 is that the UI, the durable log and Prometheus agree on +logical and physical counts and on terminal classification. They did not, and the disagreement was +not subtle: + +- Three surfaces classified a terminal three different ways. The durable row carried + `terminalStatus` and `closeReason`; the exporter kept its own private `classifyResult`; the + dashboard read the numeric HTTP status and nothing else. A turn cut short by + `max_output_tokens` is durably `status: 200, terminalStatus: "incomplete"`, which the exporter + reported as `incomplete` and the dashboard rendered as a green 200. The metric said incident and + the operator saw success, for the same request. +- The dashboard showed no physical send count at all. `sendCount` and `spend` were never rendered, + so an attempt that sent three times appeared as one row with nothing to say otherwise. +- The dashboard's recovery-kind union had drifted to nine of the durable thirteen, so `key-401`, + `oauth-account-429`, `opaque-blob-rejection` and `reasoning-effort-downgrade` all reached the + operator as "Unknown recovery reason" — four real causes rendered as the absence of one. + +The fix is one classifier in `src/usage/request-outcome.ts` that the exporter imports, the +management payload already carries, and the dashboard calls. Agreement is structural rather than a +rule someone maintains. The data was never missing: `requestLogDto` spreads the whole durable +entry, so the page only had to declare the fields and stop reinventing the precedence. + +The exporter's result label set **is** the shared vocabulary rather than a copy of it. Restating +those four strings is what let the two drift while both looked correct. + +### The dashboard may only reach a contract leaf + +Adversarial review caught this before the first push and it is worth recording, because the +mistake is invisible from the backend side. The dashboard is a separate TypeScript project with +`erasableSyntaxOnly`, and a **type-only** import still pulls the imported file's entire import +graph into that project. Importing the recovery roster from `src/usage/log.ts` therefore dragged +`node:fs`, `node:crypto` and the config barrel into the browser build, where a parameter property +in `src/config/atomic-write.ts` does not compile. "It is only a type import" is not a defence. + +So the names a browser legitimately needs now live in `src/usage/telemetry-contract.ts`, which has +no imports at all and must keep none. `src/usage/log.ts` re-exports them so every existing importer +keeps its path, and `src/lib/request-failure-model.ts` stopped depending on the ledger module as a +side effect. Three cases hold the boundary: the page must not name `src/usage/log`, the contract +must have no imports, and the outcome module must reach nothing but the contract. + +The same review caught the send total disagreeing in the other direction. An earlier draft reported +`max(sends, reserved)`, on the reasoning that a budget charge with no attempt row behind it is +still a send that left. That is true, and it still made the dashboard say four where the exporter, +summing the same attempts the recorder summed, said three. Two defensible formulas are two answers; +the surfaces now read the recorded totals and recompute nothing, and a case asserts the exporter's +`opencodex_physical_sends_total` equals what the dashboard shows. + +## Dispositions, updated + +### Who is credited, and why only one + +Attribution follows what was actually taken, not what was read. Only #2366's work is carried here, +so only its author carries a `Co-authored-by` trailer, and that trailer sits in a branch commit so +it survives the squash. The other three were analysed in depth and their designs informed the +deferral reasons below, but no line of their work is in this branch; crediting them would claim a +landing that did not happen and would make the contributor graph say something false. + +A note on the gate, because getting this right took two attempts. `pr-carry-attribution.cjs` looks +for a carry verb and reads the pull request numbers in the eighty characters after it. A first +draft of the description read "#2366's rehydration half is carried and ... ; #3748 is blocked", +which put #3748 inside that window and asked for a trailer naming an author whose work is +deliberately absent. Rewording split the sentences — but it also moved #2366 out of every window, +so the check went green by having nothing left to check. A gate that passes because the trigger was +removed is not evidence. The provenance sentence now names #2366 after the verb, in the commit +itself, so the check resolves the author and matches the trailer instead of skipping. + +### #2366 (chilung-cgu) — partially carried + +**Carried:** the rehydration and UI-projection half. The durable terminal facts now reach an +operator instead of stopping at the API boundary. + +**Not carried, and why:** `FailureSide` and the seven-member `FailureStage` are a second +attribution vocabulary beside the one that just landed, and defining two is the exact class that +blocked 2.60.0. The PR also widens `transportPhase` and `terminalSource` from their existing closed +unions to arbitrary strings, which would let bounded upstream-controlled text into the durable row; +those validators stay. Copying the request-relative timeline into an attempt at finalization is +simply wrong — request-relative elapsed values do not become attempt-relative by being copied. + +**Next step, specified:** persist `failureStage?: RequestFailureStage` and +`failureCause?: RequestFailureCause` on `PersistedUsageAttempt`, projected onto the entry, with +`resendPermission` computed at read time and never persisted. That is the smallest durable record +that makes a failure attributable, and it is the prerequisite for #3748 below. It is not in this +branch because it is new classification logic on the finalization path, and a branch whose only +verification is static review plus hosted CI should not add a new derivation and the surface that +consumes it in the same change. + +### #3748 (yansigit) — still deferred, reason updated + +The earlier reason was the parallel SQLite store. That still holds, but the blocking reason today is +narrower and more useful: **the recorder does not yet record why a request finally failed.** +`causeForRecoveryKind` answers why a *recovery* was attempted, which is a different question — a +request that failed without any recovery, or that recovered and then failed for another reason, +has no cause to group by. A derived failure ledger therefore cannot compute a grouping key today +without reading `errorCode` or `upstreamError`, which are open strings. + +The design is otherwise settled and should be built once the field above exists: group failed rows +scanned through the existing `scanUsageLedgerCooperatively` by a versioned fingerprint over closed +vocabularies only — cause, status class, inbound protocol, terminal status, close reason, transport +phase, terminal source — with fixed tuple positions so a missing field cannot collide structurally. +No provider, no model, no account label, no free-text signature. First-seen, last-seen and count +fall out of the scan; no second timestamp list is retained. + +Two parts of the original are not derivable from request history at all: the mutable +`monitoring/dispatched/fixed/ignored` remediation status and its free-text notes. Those are +operator state, not event history, and need their own owner rather than being presented as a +derived ledger. + +### #3983 (yansigit) — still deferred as an emission path, reason updated + +The earlier reason was "a second emission path". The updated reason is stronger: the path is not +ephemeral. `emitDebugLine` writes the in-process ring **and** stderr, and stderr is redirected to +the service log under both launchd and systemd, so an installed service gets a durable per-event +record with its own retention, sequencing, request identity and masking — beside the ledger and +sourced from something other than it. + +Two further facts: four of its eighteen files no longer apply, including +`run-turn-execution.ts` where carrying it literally would regress the current send-budget +accounting; and its per-content HMAC is a process-global random key, so equality of every prompt, +tool name and error message is correlatable for the process lifetime. + +**The useful half, specified:** a bounded normalized summary on the attempt — adapter events, +actually relayed events, semantic bytes, side-effect events, terminal events — counted where the +event is delivered rather than where it is read. That keeps the signals worth having (missing +terminal, adapter-to-relay loss, empty output, partial output size) and inherits the ledger's +normalization, masking and retention instead of inventing its own. + +### #5063 (Vocllum) — still deferred, reason updated + +The earlier reason was "a separate product slice with GUI surface". The updated reason is a +correctness one found while reviewing it against current `dev`: + +- Retention captures the file size, copies a retained suffix to a temp file and renames. A row + appended by **another process** between the size snapshot and the rename is silently dropped. + The PR's own "concurrent re-entrancy" test performs two sequential calls and says it cannot test + true concurrency. +- Both the temp-file fsync and the parent-directory fsync failures are swallowed, and replacement + proceeds anyway. There is no revision, inode or digest comparison immediately before the rename. +- It hand-rolls the copy rather than using `src/config/atomic-write.ts`. +- It does not clear the 2,000-entry request-log ring, so rows deleted from disk stay visible + through `/api/logs` until eviction or restart. +- `src/server/index.ts` is 892 lines against a 893-line cap, so the startup wiring cannot land as + written without offsetting a line elsewhere. + +Retention on the canonical ledger is the right architecture and its derived-index invalidation is +correct in shape. It needs a ledger-owner serialization and revision contract first. + +### The dashboard roster defect — fixed here + +Recorded in [020_lane_c.md](020_lane_c.md) as deferred because it needed strings across ten +catalogs and a screenshot. It is fixed in this branch because the agreement condition required +touching this page anyway. The union is now derived from the durable roster and a `satisfies` +clause makes the next added kind a typecheck failure rather than a silent fallback. + +**This trips the `missing_ui_screenshot` gate.** That gate fires on changed paths under `gui/`, +not on words in the description, and this lane may not build or run the GUI, so it cannot produce +the screenshot itself. A maintainer comment or the `gui-screenshot-waived` label is the documented +resolution; the visible change is four attempt rows that said "Unknown recovery reason" now naming +their cause, plus an outcome row and a send count in the detail dialog. + +## Retention and masking, stated in one place + +The instruction was to unify rather than add a fifth policy. The honest form of that today is to +record what already exists and bind projections to it, because every bound below is already +enforced and none of them needs a new owner: + +| Surface | Bound | +| --- | --- | +| Usage ledger rows (scanner) | 1 MiB per row, 1 MiB read chunks, 64 KiB boundary digest | +| Startup request-log hydration | 64 MiB read ceiling | +| Management usage snapshot | 64 MiB, 500,000 rows | +| Usage metadata strings | sanitized, capped at 64 characters | +| Upstream error text | `redactSecretString`, capped at 500 characters | +| Conversation correlation | rejects over 4,096 characters, persists a 32-hex digest | +| Debug ring | 2,000 lines, 16 KiB per line, 64 subscribers | +| Request-log ring | 2,000 entries plus app-owned-memory eviction | +| Routing history | rebuildable projection; pages capped, records over 1 MiB omitted | +| Email masking | masked unless `privacy.maskEmails: false` | + +The policy this lane adopts: **masking happens at capture and projection boundaries, retention +deletes canonical rows, and every derived surface inherits both.** A projection does not get its +own TTL, its own row cap or its own redaction pass. The failure projection specified above obeys +this by construction — it holds only aggregates and a scanner checkpoint, and discards them when +the source is replaced. + +## Issues + +#4191 and #5180 stay open and are not closed here. What narrowed: the dashboard now reports the +terminal classification and the send count the durable row always carried, so an operator can tell +an incomplete turn from a successful one without reading the ledger. What remains unchanged: the +WebSocket-to-SSE fallback for #4191, and the shared cooldown and `Retry-After` handling for #5180. + +## Verification + +Static source review plus exact-head hosted CI. + +NOT RUN on this branch, by instruction: `bun run test`, any individual `bun test` file, +`bun run typecheck`, `bun run build:gui`, `bun run lint:gui`, `bun install`, +`bun run structure:check`, `bun run privacy:scan`, and any live `ocx` execution. None of these may +be recorded as passing. + +Checked statically on this branch: + +- all eleven new label keys are present in all ten catalogs, and the catalog edits are purely + additive (+11 lines, 0 removed, per file); +- the ten catalogs are explicitly exempt from the file-size ratchet, for the reason the exemption + list gives: they grow by one line per UI string across every locale at once; +- no ratchet-capped file is touched by this branch; +- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` agree key for + key, and the new test's regex seed resolves to the same domain it is registered to, which is the + oracle that failed lane C on its first push; +- no test restates a source constant: the outcome vocabulary, the recovery roster and the label + keys are all read from the modules that declare them. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bb936060c12..d100c210015 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -873,6 +873,17 @@ export const de: Record = { "logs.detail.attempt.recovery.image413": "Bildnutzlast zu groß (413)", "logs.detail.attempt.recovery.emptyCompletion": "Wiederholung nach leerer Antwort", "logs.detail.attempt.recovery.consoleGoUpload": "Console-Upload erneut versucht", + "logs.detail.attempt.recovery.key401": "API-Schlüssel erneut authentifiziert", + "logs.detail.attempt.recovery.oauthAccount429": "Konto rate-limitiert (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "Veralteter verschlüsselter Zustand verworfen", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Reasoning-Aufwand reduziert", + "logs.detail.outcome.label": "Ergebnis", + "logs.detail.outcome.completed": "Abgeschlossen", + "logs.detail.outcome.failed": "Fehlgeschlagen", + "logs.detail.outcome.incomplete": "Unvollständig", + "logs.detail.outcome.aborted": "Abgebrochen", + "logs.detail.sends.label": "Upstream-Sendungen", + "logs.detail.sends.unresolved": "ungeklärt", "logs.detail.attempt.recovery.unknown": "Unbekannter Wiederherstellungsgrund", "logs.detail.reason.usage_missing": "Nutzung wurde nicht gemeldet.", "logs.detail.reason.usage_unsupported": "Dieser Anbieter meldet keine Nutzung.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 93af425073b..50876671abd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -922,6 +922,17 @@ export const en = { "logs.detail.attempt.recovery.image413": "Image payload too large (413)", "logs.detail.attempt.recovery.emptyCompletion": "Empty completion retry", "logs.detail.attempt.recovery.consoleGoUpload": "Console upload retry", + "logs.detail.attempt.recovery.key401": "API key re-authentication", + "logs.detail.attempt.recovery.oauthAccount429": "Account rate-limited (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "Stale encrypted state dropped", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Reasoning effort downgraded", + "logs.detail.outcome.label": "Outcome", + "logs.detail.outcome.completed": "Completed", + "logs.detail.outcome.failed": "Failed", + "logs.detail.outcome.incomplete": "Incomplete", + "logs.detail.outcome.aborted": "Aborted", + "logs.detail.sends.label": "Upstream sends", + "logs.detail.sends.unresolved": "unresolved", "logs.detail.attempt.recovery.unknown": "Unknown recovery reason", "logs.detail.reason.usage_missing": "Usage was not reported.", "logs.detail.reason.usage_unsupported": "This provider does not report usage.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b81652bacd2..60f3cd69328 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -898,6 +898,17 @@ export const fr: Record = { "logs.detail.attempt.recovery.connectionReset": "Réinitialisation de la connexion", "logs.detail.attempt.recovery.emptyCompletion": "Nouvelle tentative après une réponse vide", "logs.detail.attempt.recovery.consoleGoUpload": "Nouvelle tentative d’envoi Console", + "logs.detail.attempt.recovery.key401": "Ré-authentification de la clé API", + "logs.detail.attempt.recovery.oauthAccount429": "Compte limité (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "État chiffré obsolète abandonné", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Effort de raisonnement réduit", + "logs.detail.outcome.label": "Résultat", + "logs.detail.outcome.completed": "Terminé", + "logs.detail.outcome.failed": "Échec", + "logs.detail.outcome.incomplete": "Incomplet", + "logs.detail.outcome.aborted": "Interrompu", + "logs.detail.sends.label": "Envois en amont", + "logs.detail.sends.unresolved": "non résolus", "logs.detail.attempt.recovery.oauth401": "Réauthentification OAuth", "logs.detail.attempt.recovery.key429": "Clé soumise à une limitation de débit (429)", "logs.detail.attempt.recovery.rateLimit429": "Limitation de débit (429)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7565f3c2d9a..055474b99f5 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -834,6 +834,17 @@ export const ja: Record = { "logs.detail.attempt.recovery.image413": "画像ペイロードが大きすぎます (413)", "logs.detail.attempt.recovery.emptyCompletion": "空の完了を再試行", "logs.detail.attempt.recovery.consoleGoUpload": "Console アップロード再試行", + "logs.detail.attempt.recovery.key401": "API キーの再認証", + "logs.detail.attempt.recovery.oauthAccount429": "アカウントのレート制限 (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "古い暗号化状態を破棄", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "推論強度を下げて再試行", + "logs.detail.outcome.label": "結果", + "logs.detail.outcome.completed": "完了", + "logs.detail.outcome.failed": "失敗", + "logs.detail.outcome.incomplete": "未完了", + "logs.detail.outcome.aborted": "中断", + "logs.detail.sends.label": "アップストリーム送信", + "logs.detail.sends.unresolved": "未確定", "logs.detail.attempt.recovery.unknown": "不明なリカバリ理由", "logs.detail.reason.usage_missing": "使用量が報告されませんでした。", "logs.detail.reason.usage_unsupported": "このプロバイダーは使用量を報告しません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 94bd6fcab74..25a37d602e3 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -904,6 +904,17 @@ export const ko: Record = { "logs.detail.attempt.recovery.image413": "이미지 페이로드가 너무 큼 (413)", "logs.detail.attempt.recovery.emptyCompletion": "빈 응답 재시도", "logs.detail.attempt.recovery.consoleGoUpload": "Console 업로드 재시도", + "logs.detail.attempt.recovery.key401": "API 키 재인증", + "logs.detail.attempt.recovery.oauthAccount429": "계정 속도 제한 (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "만료된 암호화 상태 제거", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "추론 강도 하향", + "logs.detail.outcome.label": "결과", + "logs.detail.outcome.completed": "완료", + "logs.detail.outcome.failed": "실패", + "logs.detail.outcome.incomplete": "미완료", + "logs.detail.outcome.aborted": "중단됨", + "logs.detail.sends.label": "업스트림 전송", + "logs.detail.sends.unresolved": "미확인", "logs.detail.attempt.recovery.unknown": "알 수 없는 복구 사유", "logs.detail.reason.usage_missing": "usage가 보고되지 않았습니다.", "logs.detail.reason.usage_unsupported": "이 프로바이더는 usage 보고를 지원하지 않습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bf7c4fcd783..132c1abd644 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -890,6 +890,17 @@ export const ru: Record = { "logs.detail.attempt.recovery.image413": "Слишком большой размер изображения (413)", "logs.detail.attempt.recovery.emptyCompletion": "Повтор пустого завершения", "logs.detail.attempt.recovery.consoleGoUpload": "Повтор загрузки Console", + "logs.detail.attempt.recovery.key401": "Повторная аутентификация API-ключа", + "logs.detail.attempt.recovery.oauthAccount429": "Ограничение частоты для аккаунта (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "Устаревшее зашифрованное состояние отброшено", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Уровень рассуждения понижен", + "logs.detail.outcome.label": "Итог", + "logs.detail.outcome.completed": "Завершено", + "logs.detail.outcome.failed": "Сбой", + "logs.detail.outcome.incomplete": "Не завершено", + "logs.detail.outcome.aborted": "Прервано", + "logs.detail.sends.label": "Отправки к провайдеру", + "logs.detail.sends.unresolved": "не подтверждено", "logs.detail.attempt.recovery.unknown": "Неизвестная причина восстановления", "logs.detail.reason.usage_missing": "Данные об использовании не были сообщены.", "logs.detail.reason.usage_unsupported": "Этот провайдер не сообщает данные об использовании.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index eb989bb3ba2..b2d7b4a5628 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -909,6 +909,17 @@ export const tr: Record = { "logs.detail.attempt.recovery.image413": "Görsel boyutu çok büyük (413)", "logs.detail.attempt.recovery.emptyCompletion": "Boş tamamlama yeniden denemesi", "logs.detail.attempt.recovery.consoleGoUpload": "Console yüklemesi yeniden denendi", + "logs.detail.attempt.recovery.key401": "API anahtarı yeniden doğrulandı", + "logs.detail.attempt.recovery.oauthAccount429": "Hesap hız sınırına takıldı (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "Eski şifreli durum bırakıldı", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Akıl yürütme düzeyi düşürüldü", + "logs.detail.outcome.label": "Sonuç", + "logs.detail.outcome.completed": "Tamamlandı", + "logs.detail.outcome.failed": "Başarısız", + "logs.detail.outcome.incomplete": "Tamamlanmadı", + "logs.detail.outcome.aborted": "İptal edildi", + "logs.detail.sends.label": "Yukarı akış gönderimleri", + "logs.detail.sends.unresolved": "çözülmemiş", "logs.detail.attempt.recovery.unknown": "Bilinmeyen kurtarma nedeni", "logs.detail.reason.usage_missing": "Kullanım bildirilmedi.", "logs.detail.reason.usage_unsupported": "Bu sağlayıcı kullanım bildirmeyebilir.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index d5b6a149f53..80c6e63cc03 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -896,6 +896,17 @@ export const vi: Record = { "logs.detail.attempt.recovery.image413": "Kích thước hình ảnh quá lớn (413)", "logs.detail.attempt.recovery.emptyCompletion": "Thử lại do hoàn thành rỗng (Empty completion retry)", "logs.detail.attempt.recovery.consoleGoUpload": "Thử lại tải lên Console", + "logs.detail.attempt.recovery.key401": "Xác thực lại khóa API", + "logs.detail.attempt.recovery.oauthAccount429": "Tài khoản bị giới hạn tần suất (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "Đã bỏ trạng thái mã hóa cũ", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Đã giảm mức suy luận", + "logs.detail.outcome.label": "Kết quả", + "logs.detail.outcome.completed": "Hoàn tất", + "logs.detail.outcome.failed": "Thất bại", + "logs.detail.outcome.incomplete": "Chưa hoàn tất", + "logs.detail.outcome.aborted": "Đã hủy", + "logs.detail.sends.label": "Số lần gửi lên nhà cung cấp", + "logs.detail.sends.unresolved": "chưa xác định", "logs.detail.attempt.recovery.unknown": "Lý do khôi phục không rõ", "logs.detail.reason.usage_missing": "Mức sử dụng không được báo cáo.", "logs.detail.reason.usage_unsupported": "Provider này không báo cáo mức sử dụng.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index fd1f07dd55e..668bbf980dd 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2319,6 +2319,17 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.image413": "圖片承載過大 (413)", "logs.detail.attempt.recovery.emptyCompletion": "空白完成重試", "logs.detail.attempt.recovery.consoleGoUpload": "Console 上傳重試", + "logs.detail.attempt.recovery.key401": "API 金鑰重新驗證", + "logs.detail.attempt.recovery.oauthAccount429": "帳號速率受限 (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "已捨棄過期加密狀態", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "已降低推理強度", + "logs.detail.outcome.label": "結果", + "logs.detail.outcome.completed": "已完成", + "logs.detail.outcome.failed": "失敗", + "logs.detail.outcome.incomplete": "未完成", + "logs.detail.outcome.aborted": "已中止", + "logs.detail.sends.label": "上游傳送次數", + "logs.detail.sends.unresolved": "未結算", "logs.detail.attempt.recovery.unknown": "未知的復原原因", "logs.detail.estimate.provider_cost_overlay": "已使用供應商設定的價格覆蓋。", "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 Priority 價格;目前顯示的估算是已知下限。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3189179e3fe..b089160d58c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -885,6 +885,17 @@ export const zh: Record = { "logs.detail.attempt.recovery.image413": "图片载荷过大 (413)", "logs.detail.attempt.recovery.emptyCompletion": "空完成重试", "logs.detail.attempt.recovery.consoleGoUpload": "Console 上传重试", + "logs.detail.attempt.recovery.key401": "API 密钥重新认证", + "logs.detail.attempt.recovery.oauthAccount429": "账号速率受限 (429)", + "logs.detail.attempt.recovery.opaqueBlobRejection": "已丢弃过期加密状态", + "logs.detail.attempt.recovery.reasoningEffortDowngrade": "已降低推理强度", + "logs.detail.outcome.label": "结果", + "logs.detail.outcome.completed": "已完成", + "logs.detail.outcome.failed": "失败", + "logs.detail.outcome.incomplete": "未完成", + "logs.detail.outcome.aborted": "已中止", + "logs.detail.sends.label": "上游发送次数", + "logs.detail.sends.unresolved": "未结算", "logs.detail.attempt.recovery.unknown": "未知的恢复原因", "logs.detail.reason.usage_missing": "未上报 usage。", "logs.detail.reason.usage_unsupported": "该提供方不支持上报 usage。", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index d7fc3ab5c49..529e62cef74 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -28,6 +28,13 @@ import { validCachedRouteDecision, } from "./log-route-decision"; import { mergeLogDelta, parseLogPollResponse } from "./log-poll"; +import type { AttemptRecoveryKind, RequestSpendTotals } from "../../../src/usage/telemetry-contract"; +import { + classifyRequestOutcome, + requestPhysicalSends, + requestUnresolvedSends, + type RequestOutcomeClass, +} from "../../../src/usage/request-outcome"; function logsCacheKey(apiBase: string): string { return `ocx.logs.list.v1:${apiBase}`; @@ -101,21 +108,6 @@ interface LogDisplayMetrics { cost: CostResult; } -/** - * Recovery kinds recorded on a log attempt; rendered as localized labels in the logs - * detail dialog instead of raw wire values. - */ -type AttemptRecoveryKind = - | "transient-5xx" - | "connection-reset" - | "oauth-401" - | "key-429" - | "rate-limit-429" - | "anthropic-oauth-429" - | "image-413" - | "empty-completion" - | "console-go-upload-retry"; - interface LogAttempt { ordinal: number; provider: string; @@ -172,6 +164,15 @@ export interface LogEntry { durationMs: number; errorCode?: string; upstreamError?: string; + /** + * Semantic terminal facts. `/api/logs` has always carried these -- `requestLogDto` spreads the + * whole durable entry -- but this page declared neither, so it classified every request by its + * numeric HTTP status alone and reported an incomplete 200 as a plain success. + */ + terminalStatus?: string; + closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow"; + /** Upstream spend for the whole logical request, aggregated across attempts and combo children. */ + spend?: RequestSpendTotals; usageStatus?: LogUsageStatus; usage?: UsageBreakdown; totalTokens?: number; @@ -298,17 +299,27 @@ const ESTIMATE_REASON_KEYS = { /** * i18n keys for every {@link AttemptRecoveryKind}, so the logs detail dialog renders a * localized label instead of the raw wire value (e.g. `rate-limit-429`). + * + * The union is now the durable roster rather than a copy of it. The copy had drifted to nine of + * thirteen members, so `key-401`, `oauth-account-429`, `opaque-blob-rejection` and + * `reasoning-effort-downgrade` all reached the operator as "Unknown recovery reason" -- four real + * causes rendered as an absence of information. `satisfies Record` is + * what now makes the next added kind a typecheck failure here instead of a silent blank. */ const RECOVERY_KIND_KEYS = { "transient-5xx": "logs.detail.attempt.recovery.transient5xx", "connection-reset": "logs.detail.attempt.recovery.connectionReset", "oauth-401": "logs.detail.attempt.recovery.oauth401", + "key-401": "logs.detail.attempt.recovery.key401", "key-429": "logs.detail.attempt.recovery.key429", "rate-limit-429": "logs.detail.attempt.recovery.rateLimit429", "anthropic-oauth-429": "logs.detail.attempt.recovery.anthropicOauth429", + "oauth-account-429": "logs.detail.attempt.recovery.oauthAccount429", "image-413": "logs.detail.attempt.recovery.image413", "empty-completion": "logs.detail.attempt.recovery.emptyCompletion", "console-go-upload-retry": "logs.detail.attempt.recovery.consoleGoUpload", + "opaque-blob-rejection": "logs.detail.attempt.recovery.opaqueBlobRejection", + "reasoning-effort-downgrade": "logs.detail.attempt.recovery.reasoningEffortDowngrade", } as const satisfies Record; /** Map a metric-unavailable reason to its i18n key. */ @@ -334,6 +345,25 @@ function verificationKey(status: MatchedPriceInfo["status"]): "logs.detail.verif return status === "verified" ? "logs.detail.verification.verified" : "logs.detail.verification.derived"; } +/** i18n key for each shared outcome class, total by construction. */ +const OUTCOME_KEYS = { + completed: "logs.detail.outcome.completed", + failed: "logs.detail.outcome.failed", + incomplete: "logs.detail.outcome.incomplete", + aborted: "logs.detail.outcome.aborted", +} as const satisfies Record; + +/** + * How this request ended, using the same classifier the Prometheus exporter uses. + * + * Calling the shared function rather than reimplementing the precedence is the point: the numeric + * status beside it can be 200 while the answer was never delivered, and reading the status first + * is exactly the disagreement this removes. + */ +function outcomeKey(entry: Pick) { + return OUTCOME_KEYS[classifyRequestOutcome(entry)]; +} + function statusColor(status: number): string { if (status >= 200 && status < 300) return "var(--green)"; if (status >= 400) return "var(--red)"; @@ -971,6 +1001,18 @@ function LogDetailDialog({

{t("logs.detail.section.basic")}

{t("logs.col.time")}{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)} + {t("logs.detail.outcome.label")} + {t(outcomeKey(detail))} + {detail.spend && ( + <> + {t("logs.detail.sends.label")} + + {requestPhysicalSends(detail.spend)} + {requestUnresolvedSends(detail.spend) > 0 + && ` (${t("logs.detail.sends.unresolved")}: ${requestUnresolvedSends(detail.spend)})`} + + + )} {t("logs.col.request")} {detail.requestId ?? "\u2014"} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 80f0a028142..7b285a4de81 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1213,6 +1213,7 @@ "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", "request-log.test.ts": "usage", + "request-outcome-agreement.test.ts": "usage", "request-pacing.test.ts": "usage", "reserve-auth-context.test.ts": "codex-integration", "reserve-availability.test.ts": "codex-integration", diff --git a/src/lib/request-failure-model.ts b/src/lib/request-failure-model.ts index 6066cdf616d..f12517f2a45 100644 --- a/src/lib/request-failure-model.ts +++ b/src/lib/request-failure-model.ts @@ -16,7 +16,7 @@ * usage or budget subsystems into a request path that did not already have them. */ import type { SendClass } from "./request-execution-budget"; -import type { AttemptRecoveryKind } from "../usage/log"; +import type { AttemptRecoveryKind } from "../usage/telemetry-contract"; /** * How far the exchange got, ordered by how much the DOWNSTREAM CLIENT observed. diff --git a/src/server/request-metrics.ts b/src/server/request-metrics.ts index 7ba966f5c45..f678b6565ba 100644 --- a/src/server/request-metrics.ts +++ b/src/server/request-metrics.ts @@ -1,9 +1,19 @@ import type { ResponsesTerminalStatus } from "../bridge"; import type { AttemptRecoveryKind } from "../usage/log"; import { type RequestFailureCause, causeForRecoveryKind } from "../lib/request-failure-model"; +import { + REQUEST_OUTCOME_CLASSES, + classifyRequestOutcome, + type RequestOutcomeClass, +} from "../usage/request-outcome"; export const REQUEST_METRICS_PROTOCOLS = Object.freeze(["responses", "chat", "messages", "unknown"] as const); -export const REQUEST_METRICS_RESULTS = Object.freeze(["completed", "failed", "incomplete", "aborted"] as const); +/** + * The exporter's result label set IS the shared outcome vocabulary, not a copy of it. Restating + * these four strings here is what let the exporter and the dashboard drift into disagreeing about + * the same request. + */ +export const REQUEST_METRICS_RESULTS = REQUEST_OUTCOME_CLASSES; /** * Closed recovery classes exported as Prometheus label values. * @@ -31,7 +41,7 @@ export const REQUEST_DURATION_BUCKETS_SECONDS = Object.freeze([0.1, 0.25, 0.5, 1 export const REQUEST_TTFT_BUCKETS_SECONDS = Object.freeze([0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30] as const); export type RequestMetricsProtocol = typeof REQUEST_METRICS_PROTOCOLS[number]; -export type RequestMetricsResult = typeof REQUEST_METRICS_RESULTS[number]; +export type RequestMetricsResult = RequestOutcomeClass; export type RequestMetricsRecoveryClass = typeof REQUEST_METRICS_RECOVERY_CLASSES[number]; export interface RequestMetricFinalFact { @@ -84,18 +94,6 @@ function histograms(bounds: readonly number[]): HistogramCell[][] { )); } -function classifyResult(fact: RequestMetricFinalFact): RequestMetricsResult { - if (fact.closeReason === "client_cancel" || fact.status === 499) return "aborted"; - if (fact.terminalStatus === "failed") return "failed"; - if (fact.terminalStatus === "incomplete" - || fact.closeReason === "body_stall" - || fact.closeReason === "body_overflow") return "incomplete"; - if (fact.terminalStatus === "completed") return "completed"; - if (fact.terminalStatus === undefined - && (fact.status === 101 || (fact.status >= 200 && fact.status < 400))) return "completed"; - return "failed"; -} - /** * Metrics class for each shared failure cause. * @@ -176,7 +174,7 @@ export function createRequestMetricsOwner( return { recordFinalRequest(fact): void { const protocol: RequestMetricsProtocol = fact.protocol ?? "unknown"; - const result = classifyResult(fact); + const result = classifyRequestOutcome(fact); const protocolIndex = protocolCell(protocol); const resultIndex = resultCell(result); logicalRequests[protocolIndex]![resultIndex]! += 1; diff --git a/src/usage/log.ts b/src/usage/log.ts index b1bd2193e02..4a0be286bcb 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -12,6 +12,18 @@ import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routi import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility"; import type { CodexWsStageRecord } from "../server/responses/codex-ws-wire"; +import { + ATTEMPT_RECOVERY_KIND_ROSTER, + ATTEMPT_RECOVERY_WITHHELD_ROSTER, + type AttemptRecoveryKind, + type AttemptRecoveryWithheld, + type RequestSpendTotals, +} from "./telemetry-contract"; + +// Re-exported so every existing importer keeps its path. The declarations moved to a leaf the +// dashboard can import without pulling node:fs and the config barrel into the browser build. +export { ATTEMPT_RECOVERY_KIND_ROSTER, ATTEMPT_RECOVERY_WITHHELD_ROSTER }; +export type { AttemptRecoveryKind, AttemptRecoveryWithheld, RequestSpendTotals }; export interface PersistedClaudeCompatibilityLog { decision: "shadow"; @@ -58,59 +70,6 @@ export function isCodexPoolAccountLogLabel(value: unknown): value is "main" | `p return value === "main" || (typeof value === "string" && CODEX_ACCOUNT_LOG_LABEL_RE.test(value)); } -/** - * Recovery kinds recorded per attempt in the usage log; the GUI renders localized labels - * for these wire values. - * - * The roster is the single statement of this vocabulary and the type is derived from it. It used - * to be written twice -- once as a union here, once as the read-back whitelist below -- and the - * two are not interchangeable: a member added only to the union is accepted by the compiler, - * written to disk, and then silently dropped by `normalizedAttempt`, so the row loses its reason - * on the next read. One declaration cannot drift from itself. - */ -export const ATTEMPT_RECOVERY_KIND_ROSTER = Object.freeze([ - "transient-5xx", - "connection-reset", - "oauth-401", - "key-401", - "key-429", - "rate-limit-429", - "anthropic-oauth-429", - "oauth-account-429", - "image-413", - "console-go-upload-retry", - "opaque-blob-rejection", - "empty-completion", - "reasoning-effort-downgrade", -] as const); - -export type AttemptRecoveryKind = typeof ATTEMPT_RECOVERY_KIND_ROSTER[number]; - -/** - * Why a recovery this request was otherwise willing to make did not happen. - * - * Recorded separately from `recoveryKinds` and from `sendCount`, because the question it - * answers is different from either. A log showing one physical send and no recovery kind used - * to be ambiguous: it could mean nothing was eligible, or that something was eligible and the - * send budget withheld it. Those need opposite follow-ups, and the second one was invisible - * (#5044). - * - * `sendCount` deliberately does not move for these. A refused attempt is not a physical send, - * and inflating the count to signal the refusal would corrupt the one number that means - * "requests this proxy actually made". - * - * Bounded vocabulary on purpose: it is a wire value a maintainer reads, never a credential, an - * account id, an upstream body, prompt content, or exception text. - * - * Declared as a roster for the same reason as {@link ATTEMPT_RECOVERY_KIND_ROSTER}. - */ -export const ATTEMPT_RECOVERY_WITHHELD_ROSTER = Object.freeze([ - "retry-send-budget", - "rotation-send-budget", -] as const); - -export type AttemptRecoveryWithheld = typeof ATTEMPT_RECOVERY_WITHHELD_ROSTER[number]; - /** Request-time upstream credential class, never a credential or account identifier. */ export type UsageCredentialSource = "grok-oauth" | "xai-api-key"; @@ -224,19 +183,7 @@ export interface PersistedUsageAttempt { * operator needs is the total that reached upstream carrying the full prompt. These fields are * that total, decomposed by how much of it is explained. */ -export interface PersistedRequestSpend { - /** Physical upstream sends summed across every attempt of this logical request, combo children included. */ - sends: number; - /** Sends whose attempt reached a terminal status, so the spend has a known outcome. */ - settled: number; - /** - * Sends charged with no terminal outcome behind them: an attempt abandoned mid-flight, or a - * budget charge no attempt row ever accounted for. Never folded into `settled` — an unexplained - * send is the exact quantity this record exists to make visible. - */ - unresolved: number; - /** Model sends the request execution budget charged. Absent when no budget was attached. */ - reserved?: number; +export interface PersistedRequestSpend extends RequestSpendTotals { /** Budget profile that produced `reserved`, so a count can be read against the policy it obeyed. */ policyVersion?: string; /** diff --git a/src/usage/request-outcome.ts b/src/usage/request-outcome.ts new file mode 100644 index 00000000000..9db60aee365 --- /dev/null +++ b/src/usage/request-outcome.ts @@ -0,0 +1,102 @@ +/** + * One terminal classification for a finished logical request, and the counts that go with it. + * + * Three surfaces answer "how did this request end" and they used to answer it three different + * ways. The durable row carries `terminalStatus` and `closeReason`; the Prometheus exporter had + * its own private `classifyResult`; the dashboard read the numeric HTTP status and nothing else. + * That is not a cosmetic difference. A turn cut short by `max_output_tokens` is durably + * `status: 200, terminalStatus: "incomplete"`, which the exporter reports as `incomplete` and the + * dashboard rendered as a green 200 — the operator and the metric disagreed about whether the + * user got an answer. + * + * The fix is not a third classifier. It is this one, which the exporter imports, the management + * payload carries, and the dashboard renders, so agreement is structural rather than a rule + * someone has to keep. + * + * Leaf module: its only import is a type, erased at runtime. + */ +import type { RequestSpendTotals } from "./telemetry-contract"; + +/** + * Ordered by how much of an answer the caller received. The order is not a ranking of severity; + * `aborted` is last because the caller chose it, not because it is the worst outcome. + */ +export const REQUEST_OUTCOME_CLASSES = Object.freeze([ + /** A terminal event settled the turn and the caller received the answer. */ + "completed", + /** The turn ended without an answer. */ + "failed", + /** The turn produced part of an answer and stopped. */ + "incomplete", + /** The caller went away before the turn finished. */ + "aborted", +] as const); + +export type RequestOutcomeClass = typeof REQUEST_OUTCOME_CLASSES[number]; + +/** + * The facts a terminal classification is allowed to read. + * + * Deliberately narrow, and deliberately NOT the whole durable row: an outcome that could consult + * a provider name or an error message would be a different answer per provider, which is how the + * three surfaces drifted apart in the first place. + */ +export interface RequestOutcomeFacts { + readonly status: number; + readonly terminalStatus?: string | undefined; + readonly closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow" | undefined; +} + +/** + * Classify one finished logical request. + * + * Semantic terminal facts are read BEFORE the numeric status, which is the whole point. An HTTP + * 200 that carried an incomplete terminal is incomplete; a 502 that carried an incomplete + * terminal is also incomplete, and reading the status first would have called them success and + * failure. The numeric status is consulted only when no terminal event was recorded at all. + */ +export function classifyRequestOutcome(facts: RequestOutcomeFacts): RequestOutcomeClass { + if (facts.closeReason === "client_cancel" || facts.status === 499) return "aborted"; + if (facts.terminalStatus === "failed") return "failed"; + if (facts.terminalStatus === "incomplete" + || facts.closeReason === "body_stall" + || facts.closeReason === "body_overflow") return "incomplete"; + if (facts.terminalStatus === "completed") return "completed"; + if (facts.terminalStatus === undefined + && (facts.status === 101 || (facts.status >= 200 && facts.status < 400))) return "completed"; + return "failed"; +} + +/** A count is reportable only when the writer recorded a non-negative integer. */ +function reportableCount(value: number | undefined): number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0; +} + +/** + * Physical sends a finished request made, as the one number every surface shows. + * + * This READS the recorded total rather than recomputing one. An earlier draft returned + * `max(sends, reserved)` on the reasoning that a budget charge with no attempt row behind it is + * still a send that left — true, but it made the dashboard report four where the exporter, which + * sums the same attempts the recorder summed, reported three. Two defensible formulas are still + * two answers. The recorder already decided this, and `unresolved` below is where a charge with + * no attempt behind it becomes visible. + */ +export function requestPhysicalSends(spend: RequestSpendTotals | undefined): number { + return reportableCount(spend?.sends); +} + +/** + * Sends whose attempt reached a terminal status, and sends that did not. + * + * Kept beside {@link requestPhysicalSends} because an operator reading a send total needs to know + * how much of it is explained. An unresolved send is the quantity a duplicate-send incident shows + * up in, and folding it into the total is what made #4546 invisible for so long. + */ +export function requestSettledSends(spend: RequestSpendTotals | undefined): number { + return reportableCount(spend?.settled); +} + +export function requestUnresolvedSends(spend: RequestSpendTotals | undefined): number { + return reportableCount(spend?.unresolved); +} diff --git a/src/usage/telemetry-contract.ts b/src/usage/telemetry-contract.ts new file mode 100644 index 00000000000..21e716cb580 --- /dev/null +++ b/src/usage/telemetry-contract.ts @@ -0,0 +1,83 @@ +/** + * The telemetry vocabulary both the proxy and the dashboard read. + * + * This module has NO imports, and that is its entire job. The dashboard is a separate TypeScript + * project with `erasableSyntaxOnly`, and a type-only import still pulls the imported file's whole + * import graph into that project. Importing these names from `./log` therefore dragged + * `node:fs`, `node:crypto` and the config barrel into the browser build, where a parameter + * property in `src/config/atomic-write.ts` fails to compile. The names below are the ones a + * browser legitimately needs, so they live where a browser can reach them. + * + * Anything added here must stay free of imports. A contract that acquires a dependency stops + * being a contract. + */ + +/** + * Recovery kinds recorded per attempt in the usage log; the dashboard renders localized labels + * for these wire values. + * + * The roster is the single statement of this vocabulary and the type is derived from it. It was + * written twice once -- as a union and as the read-back whitelist -- and the two are not + * interchangeable: a member added only to the union compiles, is written to disk, and is dropped + * on the next read, so the row loses the field that says why it recovered. One declaration cannot + * drift from itself, and the dashboard now reads this one rather than keeping a third copy. + */ +export const ATTEMPT_RECOVERY_KIND_ROSTER = Object.freeze([ + "transient-5xx", + "connection-reset", + "oauth-401", + "key-401", + "key-429", + "rate-limit-429", + "anthropic-oauth-429", + "oauth-account-429", + "image-413", + "console-go-upload-retry", + "opaque-blob-rejection", + "empty-completion", + "reasoning-effort-downgrade", +] as const); + +export type AttemptRecoveryKind = typeof ATTEMPT_RECOVERY_KIND_ROSTER[number]; + +/** + * Why a recovery this request was otherwise willing to make did not happen. + * + * Recorded separately from `recoveryKinds` and from `sendCount` because the question it answers + * is different from either. A log showing one physical send and no recovery kind used to be + * ambiguous: nothing was eligible, or something was and the send budget withheld it. Those need + * opposite follow-ups and the second was invisible (#5044). + * + * `sendCount` deliberately does not move for these. A refused attempt is not a physical send, and + * inflating the count to signal the refusal would corrupt the one number that means "requests this + * proxy actually made". + */ +export const ATTEMPT_RECOVERY_WITHHELD_ROSTER = Object.freeze([ + "retry-send-budget", + "rotation-send-budget", +] as const); + +export type AttemptRecoveryWithheld = typeof ATTEMPT_RECOVERY_WITHHELD_ROSTER[number]; + +/** + * What one logical request spent upstream, decomposed by how much of it is explained. + * + * The counting half of the durable spend record, without the routing detail that sits beside it. + * Every surface that reports a send total reads these three numbers and none of them recomputes a + * total of its own -- a recomputed total is how the exporter and the dashboard ended up reporting + * different send counts for the same request. + */ +export interface RequestSpendTotals { + /** Physical upstream sends summed across every attempt, combo children included. */ + sends: number; + /** Sends whose attempt reached a terminal status, so the spend has a known outcome. */ + settled: number; + /** + * Sends charged with no terminal outcome behind them: an attempt abandoned mid-flight, or a + * budget charge no attempt row ever accounted for. Never folded into `settled` -- an unexplained + * send is the exact quantity this record exists to make visible. + */ + unresolved: number; + /** Model sends the request execution budget charged. Absent when no budget was attached. */ + reserved?: number; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bcc3905de72..ccd580a96d0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1039,6 +1039,7 @@ "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", "request-log.test.ts": "usage", + "request-outcome-agreement.test.ts": "usage", "request-pacing.test.ts": "usage", "reserve-auth-context.test.ts": "codex-integration", "reserve-availability.test.ts": "codex-integration", diff --git a/tests/usage/request-outcome-agreement.test.ts b/tests/usage/request-outcome-agreement.test.ts new file mode 100644 index 00000000000..b5b30744e9e --- /dev/null +++ b/tests/usage/request-outcome-agreement.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; +import { + REQUEST_OUTCOME_CLASSES, + classifyRequestOutcome, + requestPhysicalSends, + requestSettledSends, + requestUnresolvedSends, + type RequestOutcomeFacts, +} from "../../src/usage/request-outcome"; +import { + REQUEST_METRICS_RESULTS, + createRequestMetricsOwner, +} from "../../src/server/request-metrics"; +import { ATTEMPT_RECOVERY_KIND_ROSTER } from "../../src/usage/telemetry-contract"; + +/** + * The durable ledger, the Prometheus exporter and the dashboard have to answer "how did this + * request end" and "how many times did it reach upstream" the same way. They did not: the + * exporter kept a private classifier and the dashboard read the numeric status alone, so an + * incomplete 200 was a metric incident and a green row at the same time. + * + * None of these cases can be satisfied by a request that returned 200 -- several of them are + * specifically about a 200 that must NOT read as success. + */ + +const LOCALES = ["en", "ko", "ja", "zh", "zh-TW", "de", "fr", "ru", "tr", "vi"] as const; + +function sampleValue(snapshot: string, series: string): number { + const line = snapshot.split("\n").find(row => row.startsWith(series + " ")); + return line === undefined ? Number.NaN : Number(line.slice(series.length + 1)); +} + +/** Every combination a terminal can arrive in, built from the declared vocabularies. */ +const TERMINAL_STATUSES = [undefined, "completed", "failed", "incomplete"] as const; +const CLOSE_REASONS = [ + undefined, "terminal", "client_cancel", "non_stream", "body_stall", "body_overflow", +] as const; +const STATUSES = [101, 200, 204, 399, 400, 429, 499, 500, 502] as const; + +describe("terminal classification is stated once", () => { + test("the exporter labels every fact exactly as the shared classifier does", () => { + const disagreements: string[] = []; + for (const status of STATUSES) { + for (const terminalStatus of TERMINAL_STATUSES) { + for (const closeReason of CLOSE_REASONS) { + const facts: RequestOutcomeFacts = { + status, + ...(terminalStatus ? { terminalStatus } : {}), + ...(closeReason ? { closeReason } : {}), + }; + const metrics = createRequestMetricsOwner(1); + metrics.recordFinalRequest({ + protocol: "responses", + durationMs: 1, + status, + ...(terminalStatus ? { terminalStatus } : {}), + ...(closeReason ? { closeReason } : {}), + }); + const snapshot = metrics.snapshot(); + const expected = classifyRequestOutcome(facts); + const observed = REQUEST_METRICS_RESULTS.filter(result => sampleValue( + snapshot, + `opencodex_logical_requests_total{protocol="responses",result="${result}"}`, + ) === 1); + if (observed.length !== 1 || observed[0] !== expected) { + disagreements.push(`${status}/${terminalStatus ?? "-"}/${closeReason ?? "-"}: ` + + `exporter ${observed.join("+") || "none"} != ${expected}`); + } + } + } + } + expect(disagreements).toEqual([]); + }); + + test("the exporter's label set IS the shared vocabulary, not a copy of it", () => { + expect(REQUEST_METRICS_RESULTS).toBe(REQUEST_OUTCOME_CLASSES); + }); + + /** + * The three cases the disagreement actually showed up in. Written as literals because each one + * is a specific incident shape, not a member of a roster that could grow. + */ + test("a 200 that never delivered an answer does not read as success", () => { + expect(classifyRequestOutcome({ status: 200, terminalStatus: "incomplete" })).toBe("incomplete"); + expect(classifyRequestOutcome({ status: 502, terminalStatus: "incomplete" })).toBe("incomplete"); + expect(classifyRequestOutcome({ status: 200, closeReason: "client_cancel" })).toBe("aborted"); + expect(classifyRequestOutcome({ status: 200 })).toBe("completed"); + }); + + test("the dashboard calls the shared classifier instead of reading the status", () => { + const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); + expect(page).toContain("classifyRequestOutcome"); + expect(page).toContain("request-outcome"); + }); +}); + +describe("send totals agree across surfaces", () => { + /** + * The budget charged four sends while the attempt rows account for three. Both numbers are + * real and they answer different questions, so the surfaces have to agree about WHICH one the + * send total is. An earlier draft returned max(sends, reserved) here, which is defensible on + * its own and made the dashboard say four while the exporter said three -- two defensible + * formulas are still two answers. + */ + const spend = { sends: 3, settled: 3, unresolved: 1, reserved: 4 }; + + test("the reported total is the recorded one, with the unexplained part beside it", () => { + expect(requestPhysicalSends(spend)).toBe(3); + expect(requestSettledSends(spend)).toBe(3); + expect(requestUnresolvedSends(spend)).toBe(1); + }); + + test("the exporter's send total is the number the dashboard shows", () => { + const metrics = createRequestMetricsOwner(1); + metrics.recordFinalRequest({ + protocol: "responses", + status: 200, + durationMs: 1, + terminalStatus: "completed", + attempts: [ + { sendCount: 2, recoveryKinds: [] }, + { sendCount: 1, recoveryKinds: ["connection-reset"] }, + ], + spendSends: spend.sends, + }); + const exported = sampleValue( + metrics.snapshot(), + 'opencodex_physical_sends_total{protocol="responses"}', + ); + expect(exported).toBe(requestPhysicalSends(spend)); + }); + + test("an absent or malformed spend record reports nothing rather than guessing", () => { + expect(requestPhysicalSends(undefined)).toBe(0); + expect(requestUnresolvedSends(undefined)).toBe(0); + expect(requestPhysicalSends({ sends: -2, settled: -1, unresolved: 0 })).toBe(0); + }); + + test("the dashboard shows the send total and the unresolved remainder", () => { + const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); + expect(page).toContain("requestPhysicalSends"); + expect(page).toContain("requestUnresolvedSends"); + }); +}); + +describe("the dashboard reaches only browser-safe contract modules", () => { + /** + * A type-only import still pulls the imported file's whole import graph into the dashboard's + * TypeScript project, and that project sets `erasableSyntaxOnly`. Importing these names from + * `src/usage/log.ts` dragged `node:fs`, `node:crypto` and the config barrel into the browser + * build, where a parameter property fails to compile. The page must reach the leaf instead. + */ + test("it imports the vocabulary from the contract leaf, not the ledger module", () => { + const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); + expect(page).toContain("src/usage/telemetry-contract"); + expect(page).not.toContain("src/usage/log"); + }); + + test("the contract leaf has no imports at all", () => { + const contract = readFileSync(repoPath("src", "usage", "telemetry-contract.ts"), "utf8"); + expect(contract.match(/^\s*import\s/gm)).toBeNull(); + }); + + test("the outcome module reaches nothing but the contract", () => { + const outcome = readFileSync(repoPath("src", "usage", "request-outcome.ts"), "utf8"); + const specifiers = [...outcome.matchAll(/from "([^"]+)"/g)].map(match => match[1]!); + expect(specifiers).toEqual(["./telemetry-contract"]); + }); +}); + +describe("the dashboard recovery roster cannot drift from the durable one", () => { + /** + * The defect this replaces: the page declared its own nine-member union while the ledger wrote + * thirteen, so four real causes rendered as "Unknown recovery reason". A source oracle rather + * than a type check, because the page is compiled by a different project. + */ + test("every durable recovery kind has a dashboard label", () => { + const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); + const block = page.slice(page.indexOf("const RECOVERY_KIND_KEYS"), page.indexOf("} as const satisfies Record !block.includes(`"${kind}":`)); + expect(missing).toEqual([]); + }); + + test("the page derives the union rather than restating it", () => { + const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); + expect(page).toContain("import type { AttemptRecoveryKind"); + expect(page).not.toContain('type AttemptRecoveryKind ='); + }); + + test("every label key the page names exists in all ten catalogs", () => { + const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); + const keys = [...new Set([...page.matchAll(/"(logs\.detail\.(?:attempt\.recovery|outcome|sends)\.[a-zA-Z0-9]+)"/g)] + .map(match => match[1]!))]; + expect(keys.length).toBeGreaterThan(ATTEMPT_RECOVERY_KIND_ROSTER.length); + const gaps: string[] = []; + for (const locale of LOCALES) { + const catalog = readFileSync(repoPath("gui", "src", "i18n", `${locale}.ts`), "utf8"); + for (const key of keys) if (!catalog.includes(`"${key}"`)) gaps.push(`${locale}:${key}`); + } + expect(gaps).toEqual([]); + }); +}); + +describe("the exporter stays bounded", () => { + test("no series carries a user, model, account or request identifier", () => { + const metrics = createRequestMetricsOwner(1); + for (let index = 0; index < 32; index += 1) { + metrics.recordFinalRequest({ + protocol: "responses", status: 200, durationMs: 5, terminalStatus: "completed", + attempts: [{ sendCount: 1, recoveryKinds: ["rate-limit-429"] }], + }); + } + const snapshot = metrics.snapshot(); + const labels = [...new Set([...snapshot.matchAll(/([a-z_]+)="/g)].map(match => match[1]!))]; + expect(labels.sort()).toEqual(["le", "protocol", "recovery", "result"]); + }); +}); From e37ee40addb2be759ee76ab1a5d208d671cab4d6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 19:41:07 +0900 Subject: [PATCH 15/87] fix(server): apply per-key model and provider scope to the unrouted data planes (#5290) Per-key model and provider scope (#5265) is evaluated on the resolved route, at the capture point the Responses path funnels every destination through. Four authenticated endpoints spend provider quota without resolving a model through the router, so the predicate never reached them: the standalone Images relay, file transcription and the dictation socket, voice call-create and the realtime sockets, and both unrouted branches of /v1/alpha/search. Each one now applies the landed predicate where its destination becomes concrete, never to the string the client sent. Images checks the provider it settles on and the model the caller named, and checks the xAI bridge and the Antigravity fallback against the model each of those picks for itself. Audio and voice resolve through one upstream decision, so one check on each of its return paths covers transcription, dictation, external call-create and the sideband join; a refused forward request releases its probe lease. The native voice relay reads the model from the call-create session or the socket query. The search relay checks the account an unqualified model resolved to, and checks the sidecar fallback against the backend and model the operator configured. A destination nobody named -- a body with no model, or a join onto a call this process never recorded -- refuses a key that carries a model list, since no entry in that list can describe it. The external voice path records the model a call settled on in its binding so a rejoin is judged against it. A provider-only scope is judged on the provider alone, and a key with no scope behaves exactly as before on every surface. No new policy system: the denial helper composes the existing scope resolution, predicate and 403 response for handlers that return a Response instead of throwing into a route resolver. Nothing here reads or moves a credential, and no logging was added; a refusal carries only the selector the caller already sent. Four endpoint-level regression suites cover the refusals, that no upstream call is attempted, that an allowed scope still reaches its destination, and that an unscoped key stays unrestricted. Refs #5049. The issue stays open: this covers the four endpoints named in the #5265 review and nothing beyond them. --- .../060_lane_i.md | 112 ++++++++ scripts/test-layout/layout.json | 6 +- src/server/admission-model-scope.ts | 59 +++- src/server/audio-live.ts | 12 +- src/server/audio-upstream.ts | 18 ++ src/server/images.ts | 65 ++++- src/server/index/serve-options.ts | 6 +- src/server/live-call-bindings.ts | 6 + src/server/live.ts | 91 ++++++- src/server/search.ts | 28 +- src/web-search/alpha-search.ts | 22 +- structure/data-planes/images.md | 8 + structure/data-planes/inbound-compat.md | 14 + structure/data-planes/search.md | 6 + tests/fixtures/test-layout-expected.json | 6 +- .../server/api-key-scope-alpha-search.test.ts | 252 ++++++++++++++++++ tests/server/api-key-scope-audio.test.ts | 250 +++++++++++++++++ tests/server/api-key-scope-images.test.ts | 202 ++++++++++++++ tests/server/api-key-scope-live.test.ts | 235 ++++++++++++++++ 19 files changed, 1369 insertions(+), 29 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/060_lane_i.md create mode 100644 tests/server/api-key-scope-alpha-search.test.ts create mode 100644 tests/server/api-key-scope-audio.test.ts create mode 100644 tests/server/api-key-scope-images.test.ts create mode 100644 tests/server/api-key-scope-live.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/060_lane_i.md b/devlog/_plan/260920_meaning_preservation_batch/060_lane_i.md new file mode 100644 index 00000000000..bef4c28b42a --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/060_lane_i.md @@ -0,0 +1,112 @@ +# Lane I — per-key scope on the unrouted data planes + +Status: OPEN. Branch `codex/260920-lane-i-key-scope-dataplane` against `dev`, one pull request, +ordered commits. Base is `origin/dev` at `043aa435ff`. + +Scope is one thing: the part of #5049 that #5265 (`447ac22ca6`) could not reach. That change put +the model and provider scope on the resolved route, at the single capture point every Responses +destination passes through, and Chat and Messages inherit it by translating into that path. Four +authenticated endpoints spend provider quota without ever resolving a model through the router, so +the predicate never saw them. + +## What each surface turned out to be + +### Images + +`handleImages` can settle on four different destinations, and only two of them use the model the +caller sent. The ChatGPT forward account and a keyed OpenAI provider relay the body verbatim; the +xAI Imagine bridge always runs `images.bridgeModel` on the configured xAI provider; the Antigravity +fallback always runs its own CCA image model. Judging the body would therefore have authorized one +thing and billed another on two of the four branches, which is the failure the landed design names. + +Each branch is checked as it is entered — the bridge and the Antigravity fallback before they +resolve a credential, the two relays before the forward probe lease is consumed or the keyed picker +commits a rotation. A refused request spends nothing and mutates nothing. + +### Audio and voice + +Transcription, the dictation socket, external voice call-create and the external sideband join all +resolve through `resolveAudioUpstream`, which already receives both the admission and the model the +upstream will run. One check on each of its two return paths covers all four endpoints. The forward +path releases its probe lease on refusal, mirroring the adjacent unusable-account branch. + +The native `/v1/live` and `/v1/realtime/calls` path does not share that resolver, so it needed its +own: `resolveLiveRelay` now takes the destination it is resolving for. The model is read where the +client states it — `session.model` in a JSON or multipart call-create, the `model` parameter of a +standalone socket query — and a join onto an existing call carries the default, because the call it +attaches to stated its model when it was created. + +### Search + +The brief expected the non-account-qualified branch to hand the caller's model to the sidecar. It +does not, and the distinction matters for where the check belongs. There are two unrouted branches, +not one: + +- the forward relay copies the caller's model to whichever ChatGPT account the upstream resolved, + and that account is billed for it; +- the sidecar fallback ignores the caller's model entirely and runs the backend and model the + operator configured, spending that backend's own credential. + +Both are destinations a scoped key must not reach, so both are checked — the first against the +resolved account and the caller's model, the second against the configured backend and the model +that backend runs. Exa has no provider entry, so its backend name is its destination. The +account-qualified branch keeps the single check #5265 gave it and is not judged twice. + +## Rules that fell out of the review + +A request that names no model has no destination a model list can allow: the relay would copy the +body and let the upstream pick. `UNNAMED_DESTINATION_MODEL` makes that explicit, so a key scoped by +model is refused rather than sent to a provider default. A key scoped only by provider is +unaffected, and a key with no scope at all reaches every surface exactly as before. + +No new policy system was introduced. `admissionScopeDenial` is a three-line composition of the +landed `resolveAdmissionModelScope`, `routeAllowedByScope` and `admissionModelDeniedResponse`, +shaped for handlers that return a `Response` rather than throwing into a route resolver. Every +refusal is the same 403 naming the caller's own selector, with the resolved destination left to the +server log. + +## Explicitly out of scope + +Redis, multi-tenancy, budgets and RPM/TPM ceilings. Nothing here reads or writes a credential, and +no new field is logged: a refusal carries the selector the caller already knows and no account +identifier, provider credential or request body. + +## Verification + +Static review against the `dev` source plus exact-head hosted CI. Per the lane instruction the +local suite, focused test files, `typecheck`, `build`, `install` and any running `ocx` were NOT +RUN; the file-size ratchet and the union-exhaustiveness classes were checked by reading the +baseline and the changed files instead. No touched file carries a baseline cap, and the largest, +`src/server/index/serve-options.ts`, stays well under the 2000-line threshold. The four new test +files are registered in both `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. + +## Remaining scope on #5049 + +The issue stays open for the coordinator to judge. This lane closes the four endpoints named in the +#5265 adversarial review and nothing beyond them. + +## What the first review round changed + +Three findings on PR #5290, all of them about a destination the first pass was willing to assume. + +The refusal marker was being used as a model id. A request that named no model was checked as the +literal `(unnamed)`, which the configuration schema accepts like any other string, so an operator +who copied it out of a refusal into `allowedModels` would have granted "whatever the upstream +picks". The absent model is now absent: the denial helper takes an undefined model id and refuses +any key carrying a model list, judging a provider-only scope on the provider alone. The marker is +message vocabulary and never reaches the comparison. + +A Realtime standalone socket was judged as the default. The external audio path read the `model=` +query only for the Frameless style — the one that rewrites its own query — while a +`realtime-standalone` socket forwards that parameter untouched. Both standalone styles now report +the model they forward, through the helper the native path already used. + +A join was authorized against an assumed model, and what to do about it depends on what each path +can know. The external path keeps a per-key call registry, so the model a call settles on is now +recorded in its `LiveCallBinding` and a rejoin is judged against it. The native compatibility path +records nothing about the calls it relays and does not gain a registry here — adding call ownership +to it is a different change from closing a scope hole — so a native join, and a native call-create +that sends no session model, name no destination and a key carrying a model list is refused. That +is a real restriction on model-scoped keys and it is written down beside the contract it +constrains, in `structure/data-planes/inbound-compat.md`, rather than left to be rediscovered. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7b285a4de81..943a04250e0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1584,7 +1584,11 @@ "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server", - "docs-provider-discovery-limits.test.ts": "ci-workflows" + "docs-provider-discovery-limits.test.ts": "ci-workflows", + "api-key-scope-images.test.ts": "server", + "api-key-scope-audio.test.ts": "server", + "api-key-scope-live.test.ts": "server", + "api-key-scope-alpha-search.test.ts": "server" }, "migrated": [ "adapters", diff --git a/src/server/admission-model-scope.ts b/src/server/admission-model-scope.ts index 4ffe9380827..9390d2c3f34 100644 --- a/src/server/admission-model-scope.ts +++ b/src/server/admission-model-scope.ts @@ -37,6 +37,11 @@ export interface ScopedRoute { const normalize = (value: string): string => value.trim().toLowerCase(); +/** Does this scope admit this provider on its own terms? */ +function providerAllowedByScope(scope: AdmissionModelScope, providerName: string): boolean { + return scope.providers.length === 0 || scope.providers.includes(normalize(providerName)); +} + function normalizedList(values: readonly string[] | undefined): readonly string[] { if (!Array.isArray(values)) return []; const seen = new Set(); @@ -88,11 +93,10 @@ export function routeAllowedByScope( route: ScopedRoute, ): boolean { if (!scope) return true; - const provider = normalize(route.providerName); const model = normalize(route.modelId); - if (scope.providers.length > 0 && !scope.providers.includes(provider)) return false; + if (!providerAllowedByScope(scope, route.providerName)) return false; if (scope.models.length === 0) return true; - return scope.models.includes(model) || scope.models.includes(provider + "/" + model); + return scope.models.includes(model) || scope.models.includes(normalize(route.providerName) + "/" + model); } /** @@ -119,6 +123,16 @@ export class AdmissionModelDeniedError extends Error { /** Stable wire type for a scope refusal. */ export const MODEL_NOT_ALLOWED_FOR_KEY = "model_not_allowed_for_key"; +/** + * How a refusal names a destination whose model nobody stated. + * + * This is message vocabulary, never a model id. It is not compared against + * `allowedModels`, so an operator who copies it out of a refusal into a list + * cannot grant "whatever the upstream picks" — the one destination a model list + * is unable to describe. + */ +export const UNNAMED_DESTINATION_MODEL = "(unnamed)"; + /** The HTTP body a scope refusal returns. 403: authenticated, not permitted. */ export function admissionModelDeniedBody(error: AdmissionModelDeniedError): { error: { type: string; message: string; model: string }; @@ -164,3 +178,42 @@ export function assertRouteAllowedByScope( throw new AdmissionModelDeniedError(requestedModel, route); } } + +/** + * The refusal a non-routed data-plane surface returns, or undefined when the + * destination is permitted. + * + * The Responses path resolves a route inside a try/catch and throws from the + * point the destination becomes concrete. The endpoints beside it -- images, + * audio, voice and the search relay -- have no router and no such boundary: + * each picks its own upstream inline and returns a Response. This is the same + * predicate against the same kind of resolved destination, shaped for that + * control flow, so a key that may not reach a provider is refused identically + * whichever surface it asked through. + * + * `destination.modelId` is undefined when nobody named the model this request + * will run: the relay copies the body and the upstream picks, or a voice join + * attaches to a call this process never recorded. A key with a model list is + * then refused, because no entry in that list can describe the destination; a + * key scoped only by provider is judged on the provider alone. + */ +export function admissionScopeDenial( + config: Pick, + admission: DataPlaneAdmission | undefined, + requestedModel: string | undefined, + destination: { readonly providerName: string; readonly modelId: string | undefined }, +): Response | undefined { + const scope = resolveAdmissionModelScope(config, admission); + if (!scope) return undefined; + const allowed = destination.modelId === undefined + ? providerAllowedByScope(scope, destination.providerName) && scope.models.length === 0 + : routeAllowedByScope(scope, { providerName: destination.providerName, modelId: destination.modelId }); + if (allowed) return undefined; + return admissionModelDeniedResponse(new AdmissionModelDeniedError( + requestedModel ?? UNNAMED_DESTINATION_MODEL, + { + providerName: destination.providerName, + modelId: destination.modelId ?? UNNAMED_DESTINATION_MODEL, + }, + )); +} diff --git a/src/server/audio-live.ts b/src/server/audio-live.ts index 2f429ace694..ac3491d4110 100644 --- a/src/server/audio-live.ts +++ b/src/server/audio-live.ts @@ -11,7 +11,8 @@ import { LIVE_AUDIO_MODEL, resolveAudioUpstream, type AudioUpstream } from "./au import { registerTurn, unregisterTurn } from "./lifecycle"; import { backendJsonBodyFromApiMultipart, buildLiveSidebandUpstreamWsUrl, forwardLiveUrl, keyedLiveUrl, - LIVE_CLIENT_PROTOCOL_HEADERS, LIVE_REQUEST_MAX_BYTES, LIVE_RESPONSE_MAX_BYTES, readBodyCapped, + LIVE_CLIENT_PROTOCOL_HEADERS, LIVE_REQUEST_MAX_BYTES, LIVE_RESPONSE_MAX_BYTES, liveSidebandModel, + readBodyCapped, type LiveSidebandTarget, } from "./live"; import { LIVE_CALL_TTL_MS, LiveCallBindings, upstreamLiveCallId } from "./live-call-bindings"; @@ -115,6 +116,8 @@ export async function handleExternalLive( owner: options.client.owner, upstreamCallId: callId, joinStyle: frameless ? "frameless-path" : "realtime-query", providerName: relay.providerName, + // A join carries no model of its own; this is the one the call settled on. + model, accountId: context ? context.kind === "main" ? callerOwned ? undefined : MAIN_CODEX_ACCOUNT_ID : context.accountId : undefined, chatgptAccountId: new Headers(relay.headers).get("chatgpt-account-id") ?? undefined, keyedCredentialDigest: relay.keyed ? createHash("sha256").update(new Headers(relay.headers).get("authorization") ?? "").digest("hex") : undefined, @@ -150,13 +153,16 @@ export async function resolveExternalLiveSocket( } let upstreamTarget = binding ? { style: binding.joinStyle, callId: binding.upstreamCallId } as LiveSidebandTarget : target; const frameless = upstreamTarget.style === "frameless-path" || upstreamTarget.style === "frameless-standalone"; - let model = LIVE_AUDIO_MODEL; if (upstreamTarget.style === "frameless-standalone") { const query = new URLSearchParams(upstreamTarget.query); if (!query.has("model") || query.get("model") === "gpt-live-1") query.set("model", LIVE_AUDIO_MODEL); - model = query.get("model") ?? LIVE_AUDIO_MODEL; upstreamTarget = { ...upstreamTarget, query: query.toString() }; } + // What this socket will actually run: the model a bound call settled on when + // this same key created it, or the one a standalone session forwards in its + // own query. Reading only the Frameless query left a Realtime standalone + // socket judged as the default while its query carried another model. + const model = binding?.model ?? liveSidebandModel(upstreamTarget) ?? LIVE_AUDIO_MODEL; const relay = await resolveAudioUpstream(client.headers, config, log, { admission: client.admission, model, lease: options.lease, signal: options.signal, diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts index fa8bc4e9e7d..cefeaee2665 100644 --- a/src/server/audio-upstream.ts +++ b/src/server/audio-upstream.ts @@ -14,6 +14,7 @@ import { formatCodexProviderForLog } from "../codex/routing"; import type { AdmissionLease } from "../lib/admission"; import { captureExplicitOpenAiCallerAuth, resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar"; import type { OcxConfig } from "../types"; +import { admissionScopeDenial } from "./admission-model-scope"; import { isProxyAdmissionSecret, resolveDataPlaneAdmissionSecret, @@ -119,6 +120,18 @@ export async function resolveAudioUpstream( return formatErrorResponse(401, "authentication_error", "Selected audio account is unavailable"); } validateForwardAdmissionCredential(selected, config); + // Every audio surface — file transcription, the dictation socket, voice + // call-create and the sideband join — resolves its upstream here, and + // `options.model` is the model that upstream will run, not a selector the + // caller can rewrite afterwards. One check therefore covers all of them. + const forwardDenial = admissionScopeDenial(config, options.admission, options.model, { + providerName: candidate.providerName, + modelId: options.model, + }); + if (forwardDenial) { + releaseCodexAuthContextProbeLease(context); + return forwardDenial; + } log.provider = formatCodexProviderForLog(candidate.providerName, context.accountId, config); log.model = options.model; return { @@ -136,6 +149,11 @@ export async function resolveAudioUpstream( const selected = new Headers(provider.headers); selected.set("authorization", `Bearer ${apiKey}`); validateForwardAdmissionCredential(selected, config); + const keyedDenial = admissionScopeDenial(config, options.admission, options.model, { + providerName, + modelId: options.model, + }); + if (keyedDenial) return keyedDenial; log.provider = providerName; log.model = options.model; return { diff --git a/src/server/images.ts b/src/server/images.ts index cf9c4d516f3..fdf944b1c3f 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -33,6 +33,8 @@ import { selectProactiveApiKeyTransport } from "../providers/key-failover"; import { getProviderRegistryEntry } from "../providers/registry"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; +import type { DataPlaneAdmission } from "./auth-cors"; +import { admissionScopeDenial } from "./admission-model-scope"; import type { RequestLogContext } from "./request-log"; import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index"; @@ -115,6 +117,18 @@ export async function readImageResponseBytes( } const CCA_IMAGE_MODEL = "gemini-3.1-flash-image"; +const XAI_IMAGE_BRIDGE_MODEL = "grok-imagine-image-quality"; + +/** + * The model this request names, or undefined when it names none. + * + * An absent `model` is relayed as an absent `model`: the upstream picks, so + * there is no destination to echo and none a model list can allow. + */ +function requestedImageSelector(body: unknown): string | undefined { + const model = (body as { model?: unknown } | null)?.model; + return typeof model === "string" && model.trim() ? model : undefined; +} /** * Google Gemini finishReasons that indicate a permanent content/safety block. @@ -207,11 +221,21 @@ async function tryCcaImageGeneration( logCtx: RequestLogContext, signal: AbortSignal, endpoint: ImagesEndpoint, + admission: DataPlaneAdmission | undefined, ): Promise { if (endpoint !== "generations") return undefined; const provider = config.providers?.["google-antigravity"]; if (!provider || provider.disabled) return undefined; + // The destination is decided here, not by the caller: this branch always + // bills Antigravity for CCA_IMAGE_MODEL whatever the body named. That is the + // pair a scoped key is held to. + const denial = admissionScopeDenial(config, admission, requestedImageSelector(body), { + providerName: "google-antigravity", + modelId: CCA_IMAGE_MODEL, + }); + if (denial) return denial; + const prompt = (body as { prompt?: unknown })?.prompt; if (typeof prompt !== "string" || !prompt.trim()) { return formatErrorResponse(400, "invalid_request_error", "prompt is required and must not be empty"); @@ -446,10 +470,20 @@ async function tryXaiImageRelay( logCtx: RequestLogContext, signal: AbortSignal | undefined, endpoint: ImagesEndpoint, + admission: DataPlaneAdmission | undefined, ): Promise { if (config.images?.bridgeEnabled !== true) return undefined; const found = findXaiProvider(config); if (!found) return undefined; + const bridgeModel = config.images?.bridgeModel ?? XAI_IMAGE_BRIDGE_MODEL; + // The bridge sends this request to the configured xAI provider and the bridge + // model regardless of the selector in the body, so that is what the key is + // checked against. + const denial = admissionScopeDenial(config, admission, requestedImageSelector(body), { + providerName: found.name, + modelId: bridgeModel, + }); + if (denial) return denial; const obj = body && typeof body === "object" && !Array.isArray(body) ? body as Record : {}; @@ -496,11 +530,11 @@ async function tryXaiImageRelay( } if (!token) return xaiImageAuthMissing(); logCtx.provider = "xai"; - logCtx.model = config.images?.bridgeModel ?? "grok-imagine-image-quality"; + logCtx.model = bridgeModel; const result = await callXaiImages( { prompt, - model: logCtx.model, + model: bridgeModel, n, size, quality, @@ -602,6 +636,7 @@ export async function handleImages( endpoint: ImagesEndpoint, logCtx: RequestLogContext, turnAdmissionLease?: AdmissionLease, + admission?: DataPlaneAdmission, ): Promise { let body: unknown; try { @@ -616,7 +651,7 @@ export async function handleImages( // Explicit images.provider owns the route, including its validation errors. // Do not divert that selection to the xAI Imagine relay. if (config.images?.provider === undefined) { - const xaiRelay = await tryXaiImageRelay(body, config, logCtx, req.signal, endpoint); + const xaiRelay = await tryXaiImageRelay(body, config, logCtx, req.signal, endpoint, admission); if (xaiRelay) return xaiRelay; } if (candidates.error) { @@ -641,7 +676,7 @@ export async function handleImages( const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0; if (!canUseOpenAiForward && !candidates.keyed) { - const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission); if (ccaResponse) return ccaResponse; // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent // configuration state that must surface on the first attempt. @@ -688,7 +723,20 @@ export async function handleImages( const headers: Record = { "content-type": "application/json" }; let url: string; + // Both relay branches copy the body upstream, so the destination is the + // provider chosen in that branch and the model the caller named. Each branch + // is checked as it is entered, before it resolves a credential or commits a + // key rotation, so a refused request spends nothing. + const relaySelector = requestedImageSelector(body); if (forward) { + const denial = admissionScopeDenial(config, admission, relaySelector, { + providerName: forward.providerName, + modelId: relaySelector, + }); + if (denial) { + forward.releaseProbeLease?.(); + return denial; + } const { provider } = forward; if (provider.headers) Object.assign(headers, provider.headers); for (const [name, value] of forward.headers) headers[name] = value; @@ -697,13 +745,18 @@ export async function handleImages( } else if (forwardAuthError) { // Before surfacing the OpenAI auth failure, try CCA — the user may have a // valid Google Antigravity login even though their OpenAI pool is broken. - const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission); if (ccaResponse) return ccaResponse; // No CCA either: a configured OpenAI pool mode owns its authentication failure. // Do not hide a broken/expired pool behind separately billed API-key image generation. return forwardAuthError; } else if (candidates.keyed) { const { providerName } = candidates.keyed; + const denial = admissionScopeDenial(config, admission, relaySelector, { + providerName, + modelId: relaySelector, + }); + if (denial) return denial; // The keyed image path builds its own URL and Authorization header and never enters // handleResponses, so the pre-dispatch key pick happens here. // @@ -737,7 +790,7 @@ export async function handleImages( url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`; } else { // No usable OpenAI credential — try CCA before giving up. - const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission); if (ccaResponse) return ccaResponse; return formatErrorResponse( 401, diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 4bd411a1ce6..053a5943919 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1270,7 +1270,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { }; const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const; return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { - const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease); + const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease, admission); addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined); return withCors(response, req, policy); }, { requestId, start, logCtx }); @@ -1558,7 +1558,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { const response = audioClient ? await handleExternalLive(req, config, logCtx, { client: audioClient, lease: turnAdmissionLease, bindings: liveCallBindings }) - : await handleLive(req, config, logCtx, turnAdmissionLease); + : await handleLive(req, config, logCtx, turnAdmissionLease, admission); addFinalRequestLog( requestId, start, @@ -1633,7 +1633,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { : liveSidebandTarget && audioClient ? await resolveExternalLiveSocket(audioClient, config, logCtx, liveSidebandTarget, { lease: turnAdmissionLease, bindings: liveCallBindings, signal: acquisition?.signal }) : liveSidebandTarget - ? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease) + ? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease, admission) : formatErrorResponse(401, "authentication_error", "opencodex API key required"); } catch (error) { try { releaseAcquisition(); } diff --git a/src/server/live-call-bindings.ts b/src/server/live-call-bindings.ts index dd90e8aa434..d7400f5e913 100644 --- a/src/server/live-call-bindings.ts +++ b/src/server/live-call-bindings.ts @@ -9,6 +9,12 @@ export interface LiveCallBinding { upstreamCallId: string; joinStyle: "frameless-path" | "realtime-query"; providerName: string; + /** + * The model this call settled on when it was created. A join names no model + * of its own, so this is what a per-key scope is judged against on reconnect + * — without it, a call created for one model could be rejoined as another. + */ + model?: string; accountId?: string; chatgptAccountId?: string; keyedCredentialDigest?: string; diff --git a/src/server/live.ts b/src/server/live.ts index 62d818fb313..673e93ea8f2 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -44,7 +44,8 @@ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; import { sidecarEnter } from "../lib/sidecar-tracker"; import type { OcxConfig } from "../types"; import { resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar"; -import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; +import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors"; +import { admissionScopeDenial } from "./admission-model-scope"; import type { RequestLogContext } from "./request-log"; import { codexLogAccountId } from "./responses"; import type { AdmissionLease } from "../lib/admission"; @@ -550,6 +551,62 @@ async function readRequestBodyCapped(req: Request, maxBytes: number): Promise { + try { + let session: unknown; + if (contentType.toLowerCase().includes("multipart/form-data")) { + const form = await new Response(body, { headers: { "content-type": contentType } }).formData(); + const raw = form.get("session"); + session = typeof raw === "string" ? JSON.parse(raw) : undefined; + } else { + session = (JSON.parse(new TextDecoder().decode(body)) as { session?: unknown } | null)?.session; + } + const model = (session as { model?: unknown } | null | undefined)?.model; + return typeof model === "string" && model.trim() ? model.trim() : undefined; + } catch { + return undefined; + } +} + +/** + * The model a sideband upgrade is for, or undefined when the request names none. + * + * A standalone session states it in the query it forwards. A join names nothing + * of its own: the call it attaches to chose a model at create time, and only a + * recorded binding can say which. This compatibility path keeps no such record, + * so a native join is undefined here and the external path in `audio-live.ts` + * supplies the model its binding stored. + */ +export function liveSidebandModel(target: LiveSidebandTarget): string | undefined { + if (target.style === "realtime-standalone" || target.style === "frameless-standalone") { + return new URLSearchParams(target.query).get("model")?.trim() || undefined; + } + return undefined; +} + /** * Resolve OpenAI/ChatGPT auth + headers for live HTTP or sideband WebSocket relays. * Shared by call-create and sideband so pool token override stays consistent. @@ -559,6 +616,7 @@ export async function resolveLiveRelay( config: OcxConfig, logCtx: RequestLogContext, turnAdmissionLease?: AdmissionLease, + destination?: LiveScopeDestination, ): Promise { try { validateForwardAdmissionCredential(req.headers, config); @@ -624,8 +682,22 @@ export async function resolveLiveRelay( // Client protocol headers first so provider/auth headers below always win on conflict. const headers: Record = clientProtocolHeaders(req.headers); + const scopedModel = destination?.model; if (forward) { const { provider } = forward; + // The upstream is settled here and voice bills it for whatever model this + // request carries. Refuse before any of it is sent, and give back the probe + // lease the resolution took. A join states no model and this path keeps no + // record of the call it attaches to, so a key with a model list is refused + // there rather than admitted against an assumed default. + const denial = admissionScopeDenial(config, destination?.admission, scopedModel, { + providerName: forward.providerName, + modelId: scopedModel, + }); + if (denial) { + forward.releaseProbeLease?.(); + return denial; + } if (provider.headers) Object.assign(headers, provider.headers); for (const [name, value] of forward.headers) headers[name] = value; logCtx.model = "gpt-live"; @@ -640,6 +712,11 @@ export async function resolveLiveRelay( if (forwardAuthError) return forwardAuthError; if (candidates.keyed) { const { provider, apiKey, providerName } = candidates.keyed; + const denial = admissionScopeDenial(config, destination?.admission, scopedModel, { + providerName, + modelId: scopedModel, + }); + if (denial) return denial; if (provider.headers) Object.assign(headers, provider.headers); headers.authorization = `Bearer ${apiKey}`; logCtx.provider = providerName; @@ -663,13 +740,17 @@ export async function handleLive( config: OcxConfig, logCtx: RequestLogContext, turnAdmissionLease?: AdmissionLease, + admission?: DataPlaneAdmission, ): Promise { const inboundContentType = req.headers.get("content-type") ?? "application/octet-stream"; const inboundBodyOrError = await readRequestBodyCapped(req, LIVE_REQUEST_MAX_BYTES); if (inboundBodyOrError instanceof Response) return inboundBodyOrError; const inboundBody = inboundBodyOrError; - const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease); + const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease, { + admission, + model: await liveCallCreateModel(inboundBody, inboundContentType), + }); if (relay instanceof Response) return relay; const headers: Record = { ...relay.headers }; @@ -766,8 +847,12 @@ export async function resolveLiveSidebandUpgrade( logCtx: RequestLogContext, target: LiveSidebandTarget, turnAdmissionLease?: AdmissionLease, + admission?: DataPlaneAdmission, ): Promise<{ headers: Record; upstreamWsUrl: string; recordOutcome?: LiveRelayTarget["recordOutcome"] } | Response> { - const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease); + const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease, { + admission, + model: liveSidebandModel(target), + }); if (relay instanceof Response) return relay; return { headers: relay.headers, diff --git a/src/server/search.ts b/src/server/search.ts index 6f9143ddaca..51e418346e9 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -26,10 +26,7 @@ import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models"; import { isCodexReserveRequestEligible } from "../codex/loopback-target"; import type { DataPlaneAdmission } from "./auth-cors"; import { - admissionModelDeniedResponse, - AdmissionModelDeniedError, - resolveAdmissionModelScope, - routeAllowedByScope, + admissionScopeDenial, } from "./admission-model-scope"; import { formatCodexProviderForLog } from "../codex/routing"; import { signalWithTimeout } from "../lib/abort"; @@ -96,9 +93,8 @@ export async function handleSearch( // This branch resolves a model through the router and bills the account it // names, so a scoped key is held to the same destination rule it is held // to on the inference path. - if (!routeAllowedByScope(resolveAdmissionModelScope(config, admission), route)) { - return admissionModelDeniedResponse(new AdmissionModelDeniedError(model, route)); - } + const denial = admissionScopeDenial(config, admission, model, route); + if (denial) return denial; exactAccount = { accountId: route.codexAccountId, modelId: route.modelId }; logCtx.provider = `${route.providerName}-${accountNamespace}`; logCtx.routeDecision = route.routeDecision; @@ -120,7 +116,7 @@ export async function handleSearch( } const candidates = listOpenAiForwardSidecarCandidates(config); if (candidates.length === 0) { - return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx); + return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx, admission); } let upstream: Awaited>; @@ -160,6 +156,22 @@ export async function handleSearch( throw err; } + if (!accountNamespace) { + // An unqualified search model is never routed: the caller's own string is + // relayed to whichever ChatGPT account this upstream resolved to, and that + // account is billed for it. The qualified branch above was already judged + // against the route it resolved, so it is not judged twice here. + const searchModel = typeof model === "string" && model.trim() ? model : undefined; + const denial = admissionScopeDenial(config, admission, searchModel, { + providerName: upstream.providerName, + modelId: searchModel, + }); + if (denial) { + upstream.releaseProbeLease?.(); + return denial; + } + } + const headers: Record = { "content-type": "application/json" }; if (upstream.provider.headers) Object.assign(headers, upstream.provider.headers); for (const [name, value] of upstream.headers) headers[name] = value; diff --git a/src/web-search/alpha-search.ts b/src/web-search/alpha-search.ts index 3bdd64853ad..1b4489bfbe5 100644 --- a/src/web-search/alpha-search.ts +++ b/src/web-search/alpha-search.ts @@ -14,6 +14,8 @@ import { formatErrorResponse } from "../bridge"; import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; +import { admissionScopeDenial } from "../server/admission-model-scope"; +import type { DataPlaneAdmission } from "../server/auth-cors"; import type { OcxConfig, OcxProviderConfig, OcxWebSearchSidecarConfig } from "../types"; import { runAnthropicWebSearch } from "./anthropic-executor"; import { runExaWebSearch } from "./exa-executor"; @@ -252,6 +254,7 @@ export async function handleAlphaSearchSidecarFallback( config: OcxConfig, signal?: AbortSignal, logCtx?: { provider: string }, + admission?: DataPlaneAdmission, ): Promise { const resolution = resolveAlphaSearchSidecar(config); if (resolution.status === "missing-credential") { @@ -266,6 +269,24 @@ export async function handleAlphaSearchSidecarFallback( const resolved = resolution.sidecar; if (logCtx) logCtx.provider = resolved.backend; + // This backend is a paid destination like any other, and the operator's + // configuration -- not the caller -- decides which one and which model. A key + // scoped away from it must not spend it by asking the search endpoint instead + // of the inference one. Exa has no configured provider entry, so its own + // backend name is the destination. + const settings = sidecarSettingsForAlphaSearch(resolved.backend, config); + const requestedModel = (body as { model?: unknown } | null)?.model; + const denial = admissionScopeDenial( + config, + admission, + typeof requestedModel === "string" && requestedModel.trim() ? requestedModel : undefined, + { + providerName: resolved.backend === "exa" ? resolved.backend : resolved.providerName, + modelId: settings.model, + }, + ); + if (denial) return denial; + const queries = extractAlphaSearchQueries(body); if (queries.length === 0) { return formatErrorResponse( @@ -275,7 +296,6 @@ export async function handleAlphaSearchSidecarFallback( ); } - const settings = sidecarSettingsForAlphaSearch(resolved.backend, config); const sidecarExit = sidecarEnter("search"); try { const texts: string[] = []; diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 691b629bdc3..13ee416cac4 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -47,6 +47,14 @@ The keyed path never enters `handleResponses`, so `src/server/images.ts` repeats `selectProactiveApiKeyTransport` inside the keyed branch and rebuilds Authorization from the returned clone rather than the earlier snapshot. +A configured key's model and provider scope applies to whichever destination the request settles +on: the ChatGPT forward account, the keyed provider, the xAI Imagine bridge, or the Antigravity +fallback. It is evaluated against that destination rather than the selector in the body, because +the bridge and the fallback choose their own model, and a body that names no model cannot satisfy +a model list. A refusal is the same 403 the scope returns on the routed path, and a key with no +scope reaches every destination as before. Coverage lives in +`tests/server/api-key-scope-images.test.ts`. + The API-key `openai-responses` path also adapts Codex's private standalone image tool to the public Responses tool surface. A complete `image_gen` namespace is lowered to safe `image_gen__` function aliases even when no hosted image tool is present, because public diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 90dacb24366..a8d1d1ba399 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -28,6 +28,20 @@ keep credentials and audio content out of redirects, request logs and durable st `tests/server/audio-transcriptions.test.ts` exercises the real ingress and synthetic upstream; `tests/server/api-key-attribution.test.ts` uses multipart fixtures for the HTTP auth matrix. +`src/server/audio-upstream.ts` is also where a configured key's model and provider scope is +applied, once for every audio surface that resolves through it: the model it is handed is the one +the upstream will run — the transcription model, the live session model, the model a standalone +socket names in its own query, or the model a bound call settled on when the same key created it — +and a refused forward request releases its probe lease. `LiveCallBinding` records that model for +exactly this reason, so a reconnect is judged on the call it rejoins rather than on a default. + +The native voice path in `src/server/live.ts` applies the same predicate but can name less. It +records nothing about the calls it relays, so a join, and a call-create that sends no session +model, name no destination at all; a key carrying a model list is refused there rather than +admitted against an assumed default, while a provider-only scope and an unscoped key are +unchanged. Coverage lives in `tests/server/api-key-scope-audio.test.ts` and +`tests/server/api-key-scope-live.test.ts`. + ## Streaming audio `src/server/audio-client.ts` recognizes explicit audio keys before local legacy admission. diff --git a/structure/data-planes/search.md b/structure/data-planes/search.md index a8ddf286def..81953f4ef83 100644 --- a/structure/data-planes/search.md +++ b/structure/data-planes/search.md @@ -16,6 +16,12 @@ find instead of asking for ChatGPT auth, and the request reaches no other backen fails answers with its own diagnostic. The fallback never runs while a forward candidate exists, so the verbatim relay stays the path for a ChatGPT deployment. +A configured key's scope covers both branches, each against what it actually reaches. An +account-qualified selector is judged against its resolved route; an unqualified one against the +account the upstream resolved and the model the body names; the fallback against the configured +backend and the model that backend runs, with Exa named by its backend because it has no provider +entry. `tests/server/api-key-scope-alpha-search.test.ts` covers the two unrouted branches. + ## Standalone Search and exact account selectors `POST /v1/alpha/search` retains the selected model in its request body. When that value is an diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ccd580a96d0..01d9da0b04d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1416,5 +1416,9 @@ "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server", - "docs-provider-discovery-limits.test.ts": "ci-workflows" + "docs-provider-discovery-limits.test.ts": "ci-workflows", + "api-key-scope-images.test.ts": "server", + "api-key-scope-audio.test.ts": "server", + "api-key-scope-live.test.ts": "server", + "api-key-scope-alpha-search.test.ts": "server" } diff --git a/tests/server/api-key-scope-alpha-search.test.ts b/tests/server/api-key-scope-alpha-search.test.ts new file mode 100644 index 00000000000..769d972a130 --- /dev/null +++ b/tests/server/api-key-scope-alpha-search.test.ts @@ -0,0 +1,252 @@ +/** + * Per-key model and provider scope on /v1/alpha/search (#5049). + * + * An account-qualified search model is resolved through the router and was + * already covered. The two branches beside it were not: an unqualified model is + * relayed verbatim to whichever ChatGPT account the upstream resolves to, and + * the sidecar fallback spends the operator configured web-search backend. Both + * bill a provider without ever naming a route. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { MODEL_NOT_ALLOWED_FOR_KEY, UNNAMED_DESTINATION_MODEL } from "../../src/server/admission-model-scope"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { handleSearch } from "../../src/server/search"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const SCOPED_KEY = "ocx_data_" + "s".repeat(40); +const OPEN_KEY = "ocx_data_" + "t".repeat(40); +const SCOPED: DataPlaneAdmission = { kind: "configured", keyId: "scoped", source: "bearer" }; +const UNSCOPED: DataPlaneAdmission = { kind: "configured", keyId: "open", source: "bearer" }; +const CALLER_TOKEN = fakeChatGptJwt({ chatgpt_account_id: "acct-123" }); +const SEARCH_MODEL = "gpt-search-test"; +const EXA_SEARCH_MODEL = "exa-search-test"; + +const TEST_DIR = join(import.meta.dir, ".tmp-alpha-search-scope"); +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; +let codexHome: IsolatedCodexHome | null = null; +let upstreamCalls: string[] = []; + +beforeEach(() => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + codexHome = installIsolatedCodexHome("ocx-alpha-search-scope-"); + upstreamCalls = []; + globalThis.fetch = (async (input: unknown) => { + const url = String(input); + upstreamCalls.push(url); + if (url.includes("exa.ai")) { + return Response.json({ + results: [{ title: "OpenAI news", url: "https://openai.com/news", text: "Latest OpenAI news." }], + }); + } + return Response.json({ encrypted_output: null, output: "search result", results: [] }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + codexHome?.restore(); + codexHome = null; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +}); + +type Scope = { allowedProviders?: string[]; allowedModels?: string[] }; + +function keys(scope: Scope): OcxConfig["apiKeys"] { + return [ + { id: "scoped", name: "mail", key: SCOPED_KEY, createdAt: "2026-01-01T00:00:00.000Z", ...scope }, + { id: "open", name: "coding", key: OPEN_KEY, createdAt: "2026-01-01T00:00:00.000Z" }, + ]; +} + +/** A ChatGPT forward provider the caller authenticates directly, as codex does. */ +function forwardConfig(scope: Scope): OcxConfig { + const config = { + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + apiKeys: keys(scope), + } as OcxConfig; + saveConfig(config); + return config; +} + +/** No forward candidate at all, which is the only state the sidecar fallback runs in. */ +function sidecarConfig(scope: Scope): OcxConfig { + const config = { + port: 0, + defaultProvider: "groq", + providers: { + groq: { adapter: "openai-chat", baseUrl: "https://api.groq.example/v1", apiKey: "gsk-x" }, + }, + webSearchSidecar: { backend: "exa", exaApiKey: "exa-fixture-key", model: EXA_SEARCH_MODEL }, + apiKeys: keys(scope), + } as unknown as OcxConfig; + saveConfig(config); + return config; +} + +function logContext(): RequestLogContext { + return { model: "web_search", provider: "unknown" } as RequestLogContext; +} + +function forwardRequest(body: Record, key = SCOPED_KEY): Request { + return new Request("http://127.0.0.1/v1/alpha/search", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencodex-api-key": key, + authorization: "Bearer " + CALLER_TOKEN, + "chatgpt-account-id": "acct-123", + }, + body: JSON.stringify(body), + }); +} + +function sidecarRequest(body: Record): Request { + return new Request("http://127.0.0.1/v1/alpha/search", { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": SCOPED_KEY }, + body: JSON.stringify(body), + }); +} + +function searchBody(model?: string): Record { + return { + id: "search-session", + ...(model ? { model } : {}), + commands: { search_query: [{ q: "OpenAI news" }] }, + }; +} + +async function denial(response: Response): Promise<{ type: string; model: string }> { + const payload = await response.json() as { error: { type: string; model: string } }; + return payload.error; +} + +test("an unqualified search model cannot reach a provider outside the scope", async () => { + const response = await handleSearch( + forwardRequest(searchBody(SEARCH_MODEL)), + forwardConfig({ allowedProviders: ["anthropic"] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + const error = await denial(response); + expect(error.type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + expect(error.model).toBe(SEARCH_MODEL); + expect(upstreamCalls).toEqual([]); +}); + +test("an unqualified search model is checked as the destination it becomes", async () => { + const response = await handleSearch( + forwardRequest(searchBody(SEARCH_MODEL)), + forwardConfig({ allowedModels: ["some-other-model"] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect(upstreamCalls).toEqual([]); +}); + +test("a search body that names no model cannot satisfy a model list", async () => { + const response = await handleSearch( + forwardRequest(searchBody()), + forwardConfig({ allowedModels: [SEARCH_MODEL] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect((await denial(response)).model).toBe(UNNAMED_DESTINATION_MODEL); +}); + +test("the relay still runs when the scope names its destination", async () => { + const response = await handleSearch( + forwardRequest(searchBody(SEARCH_MODEL)), + forwardConfig({ allowedProviders: ["openai"], allowedModels: [SEARCH_MODEL] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); + expect(upstreamCalls[0]).toContain("/alpha/search"); +}); + +test("the sidecar fallback refuses the backend it would have spent", async () => { + const response = await handleSearch( + sidecarRequest(searchBody(SEARCH_MODEL)), + sidecarConfig({ allowedProviders: ["openai"] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect((await denial(response)).type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + expect(upstreamCalls).toEqual([]); +}); + +test("the sidecar fallback is judged on the model the operator configured", async () => { + const response = await handleSearch( + sidecarRequest(searchBody(SEARCH_MODEL)), + sidecarConfig({ allowedProviders: ["exa"], allowedModels: [SEARCH_MODEL] }), + logContext(), + undefined, + SCOPED, + ); + // The caller's own selector is allowed; the backend runs a different model, + // and that is the one the scope is applied to. + expect(response.status).toBe(403); + expect(upstreamCalls).toEqual([]); +}); + +test("an allowed sidecar backend still answers the search", async () => { + const response = await handleSearch( + sidecarRequest(searchBody(SEARCH_MODEL)), + sidecarConfig({ allowedProviders: ["exa"], allowedModels: [EXA_SEARCH_MODEL] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); + expect(upstreamCalls[0]).toContain("exa.ai"); +}); + +test("a key with no scope keeps both search branches", async () => { + const response = await handleSearch( + forwardRequest(searchBody(SEARCH_MODEL), OPEN_KEY), + forwardConfig({ allowedProviders: ["anthropic"] }), + logContext(), + undefined, + UNSCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); +}); diff --git a/tests/server/api-key-scope-audio.test.ts b/tests/server/api-key-scope-audio.test.ts new file mode 100644 index 00000000000..eaa7c916402 --- /dev/null +++ b/tests/server/api-key-scope-audio.test.ts @@ -0,0 +1,250 @@ +/** + * Per-key model and provider scope on the audio and voice data planes (#5049). + * + * None of these endpoints resolves a model through the router, so the scope + * landed with #5265 did not reach them. They share one upstream decision in + * `resolveAudioUpstream`, and the model it is given is the model the upstream + * will run, so the refusal is asserted once per endpoint that reaches it. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MODEL_NOT_ALLOWED_FOR_KEY } from "../../src/server/admission-model-scope"; +import { resolveAudioClient, type AudioClient } from "../../src/server/audio-client"; +import { resolveDictationSocket } from "../../src/server/audio-dictation"; +import { handleExternalLive, resolveExternalLiveSocket } from "../../src/server/audio-live"; +import { handleAudioTranscriptions } from "../../src/server/audio-transcriptions"; +import { LIVE_AUDIO_MODEL, TRANSCRIPTION_MODEL } from "../../src/server/audio-upstream"; +import { LiveCallBindings } from "../../src/server/live-call-bindings"; +import { tryAdmitTurn } from "../../src/server/lifecycle"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { AdmissionLease } from "../../src/lib/admission"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const SCOPED_KEY = "ocx_data_" + "a".repeat(40); +const OPEN_KEY = "ocx_data_" + "b".repeat(40); +const OTHER_LIVE_MODEL = "gpt-live-1-experimental"; + +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; +let upstreamCalls: string[] = []; +let leases: AdmissionLease[] = []; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-audio-scope-")); + process.env.OPENCODEX_HOME = home; + upstreamCalls = []; + leases = []; + globalThis.fetch = (async (input: unknown) => { + upstreamCalls.push(String(input)); + return Response.json({ text: "hello" }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + for (const lease of leases.splice(0)) lease.release(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (home) removeTreeWithRetry(home); + home = ""; +}); + +type Scope = { allowedProviders?: string[]; allowedModels?: string[] }; + +/** + * A keyed OpenAI platform provider and nothing else: the audio upstream then + * resolves without a stored ChatGPT credential, so each case exercises the + * scope decision rather than account selection. + */ +function config(scope: Scope): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai-apikey", + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + apiKey: "fixture-upstream-key", + authMode: "key", + }, + }, + apiKeys: [ + { id: "scoped", name: "mail", key: SCOPED_KEY, createdAt: "2026-01-01T00:00:00.000Z", ...scope }, + { id: "open", name: "coding", key: OPEN_KEY, createdAt: "2026-01-01T00:00:00.000Z" }, + ], + } as OcxConfig; +} + +function logContext(): RequestLogContext { + return { model: "unknown", provider: "unknown" } as RequestLogContext; +} + +function lease(): AdmissionLease { + const admitted = tryAdmitTurn(); + if (!admitted) throw new Error("the turn gate refused an idle test lease"); + leases.push(admitted); + return admitted; +} + +function client(cfg: OcxConfig, key = SCOPED_KEY): AudioClient { + const resolved = resolveAudioClient( + new Request("http://localhost/v1/live", { headers: { "x-opencodex-api-key": key } }), + cfg, + ); + if (!resolved || resolved instanceof Response) throw new Error("the audio key was not admitted"); + return resolved; +} + +function transcriptionRequest(): Request { + const form = new FormData(); + form.append("file", new File([new Uint8Array([82, 73, 70, 70, 0, 0])], "sample.wav", { type: "audio/wav" })); + form.append("model", TRANSCRIPTION_MODEL); + return new Request("http://localhost/v1/audio/transcriptions", { + method: "POST", + headers: { "x-opencodex-api-key": SCOPED_KEY }, + body: form, + }); +} + +async function denial(response: Response): Promise<{ type: string; model: string }> { + const payload = await response.json() as { error: { type: string; model: string } }; + return payload.error; +} + +test("file transcription refuses a provider the key may not reach", async () => { + const cfg = config({ allowedProviders: ["some-other-provider"] }); + const response = await handleAudioTranscriptions( + transcriptionRequest(), + cfg, + logContext(), + client(cfg).admission, + ); + expect(response.status).toBe(403); + const error = await denial(response); + expect(error.type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + expect(error.model).toBe(TRANSCRIPTION_MODEL); + expect(upstreamCalls).toEqual([]); +}); + +test("file transcription still runs when the scope names its destination", async () => { + const cfg = config({ allowedProviders: ["openai-apikey"], allowedModels: [TRANSCRIPTION_MODEL] }); + const response = await handleAudioTranscriptions( + transcriptionRequest(), + cfg, + logContext(), + client(cfg).admission, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); +}); + +test("the dictation socket refuses before it resolves a credential", async () => { + const cfg = config({ allowedProviders: ["some-other-provider"] }); + const resolved = await resolveDictationSocket(client(cfg), cfg, logContext(), lease()); + expect(resolved).toBeInstanceOf(Response); + const response = resolved as Response; + expect(response.status).toBe(403); + expect((await denial(response)).type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); +}); + +test("an allowed dictation key reaches its own capability error instead", async () => { + const cfg = config({ allowedModels: [TRANSCRIPTION_MODEL] }); + const resolved = await resolveDictationSocket(client(cfg), cfg, logContext(), lease()); + expect(resolved).toBeInstanceOf(Response); + // Streaming dictation needs a ChatGPT account; the point is that the scope is + // no longer what stops it. + expect((resolved as Response).status).toBe(400); +}); + +test("voice call-create refuses the live model the session names", async () => { + const cfg = config({ allowedModels: ["gpt-4o-transcribe-only"] }); + const response = await handleExternalLive( + new Request("http://localhost/v1/live", { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": SCOPED_KEY }, + body: JSON.stringify({ sdp: "v=0" }), + }), + cfg, + logContext(), + { client: client(cfg), lease: lease(), bindings: new LiveCallBindings() }, + ); + expect(response.status).toBe(403); + const error = await denial(response); + expect(error.type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + expect(error.model).toBe(LIVE_AUDIO_MODEL); + expect(upstreamCalls).toEqual([]); +}); + +test("a standalone realtime socket is judged on the model in its own query", async () => { + const cfg = config({ allowedModels: [LIVE_AUDIO_MODEL] }); + const resolved = await resolveExternalLiveSocket( + client(cfg), + cfg, + logContext(), + { style: "frameless-standalone", query: "model=" + OTHER_LIVE_MODEL }, + { lease: lease(), bindings: new LiveCallBindings() }, + ); + expect(resolved).toBeInstanceOf(Response); + const response = resolved as Response; + expect(response.status).toBe(403); + expect((await denial(response)).model).toBe(OTHER_LIVE_MODEL); +}); + +test("a Realtime standalone socket is judged on its query too, not the default", async () => { + // The Frameless branch rewrites its query and was the only one read. A + // Realtime standalone socket forwards `model=` untouched, so reading the + // default here admitted a model the caller was never allowed to run. + const cfg = config({ allowedModels: [LIVE_AUDIO_MODEL] }); + const resolved = await resolveExternalLiveSocket( + client(cfg), + cfg, + logContext(), + { style: "realtime-standalone", query: "intent=quicksilver&model=" + OTHER_LIVE_MODEL }, + { lease: lease(), bindings: new LiveCallBindings() }, + ); + expect(resolved).toBeInstanceOf(Response); + expect((resolved as Response).status).toBe(403); + expect((await denial(resolved as Response)).model).toBe(OTHER_LIVE_MODEL); +}); + +test("rejoining a call is judged on the model that call settled on", async () => { + const cfg = config({ allowedModels: [LIVE_AUDIO_MODEL] }); + const audio = client(cfg); + const bindings = new LiveCallBindings(); + const alias = bindings.create({ + owner: audio.owner, + upstreamCallId: "call-upstream", + joinStyle: "frameless-path", + providerName: "openai-apikey", + model: OTHER_LIVE_MODEL, + callerOwned: false, + }); + expect(alias).not.toBeNull(); + const resolved = await resolveExternalLiveSocket( + audio, + cfg, + logContext(), + { style: "frameless-path", callId: alias as string }, + { lease: lease(), bindings }, + ); + expect(resolved).toBeInstanceOf(Response); + expect((resolved as Response).status).toBe(403); + expect((await denial(resolved as Response)).model).toBe(OTHER_LIVE_MODEL); +}); + +test("a key with no scope keeps every audio destination", async () => { + const cfg = config({ allowedProviders: ["some-other-provider"] }); + const response = await handleAudioTranscriptions( + transcriptionRequest(), + cfg, + logContext(), + client(cfg, OPEN_KEY).admission, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); +}); diff --git a/tests/server/api-key-scope-images.test.ts b/tests/server/api-key-scope-images.test.ts new file mode 100644 index 00000000000..754c87a50e1 --- /dev/null +++ b/tests/server/api-key-scope-images.test.ts @@ -0,0 +1,202 @@ +/** + * Per-key model and provider scope on the standalone Images relay (#5049). + * + * This endpoint never enters the router, so the scope landed with #5265 did not + * reach it: an authenticated key could spend any configured image backend. Each + * case below pins one of the four destinations this handler can choose and + * asserts the refusal happens before the paid upstream call. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MODEL_NOT_ALLOWED_FOR_KEY, UNNAMED_DESTINATION_MODEL } from "../../src/server/admission-model-scope"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import { handleImages } from "../../src/server/images"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const SCOPED_KEY = "ocx_data_" + "i".repeat(40); +const OPEN_KEY = "ocx_data_" + "o".repeat(40); +const SCOPED: DataPlaneAdmission = { kind: "configured", keyId: "scoped", source: "bearer" }; +const UNSCOPED: DataPlaneAdmission = { kind: "configured", keyId: "open", source: "bearer" }; + +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; +let upstreamCalls: string[] = []; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-images-scope-")); + process.env.OPENCODEX_HOME = home; + upstreamCalls = []; + globalThis.fetch = (async (input: unknown) => { + upstreamCalls.push(String(input)); + return Response.json({ created: 1, data: [{ b64_json: "aGk=" }] }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (home) removeTreeWithRetry(home); + home = ""; +}); + +type Scope = { allowedProviders?: string[]; allowedModels?: string[] }; + +function config(scope: Scope, overrides: Partial = {}): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai-apikey", + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + apiKey: "fixture-images-key", + authMode: "key", + }, + }, + apiKeys: [ + { id: "scoped", name: "mail", key: SCOPED_KEY, createdAt: "2026-01-01T00:00:00.000Z", ...scope }, + { id: "open", name: "coding", key: OPEN_KEY, createdAt: "2026-01-01T00:00:00.000Z" }, + ], + ...overrides, + } as OcxConfig; +} + +function imagesRequest(body: Record): Request { + return new Request("http://localhost/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer " + SCOPED_KEY }, + body: JSON.stringify(body), + }); +} + +function logContext(): RequestLogContext { + return { model: "image_gen", provider: "unknown" } as RequestLogContext; +} + +async function denial(response: Response): Promise<{ type: string; model: string }> { + const payload = await response.json() as { error: { type: string; model: string } }; + return payload.error; +} + +test("a scoped key cannot relay images through a provider it may not reach", async () => { + const response = await handleImages( + imagesRequest({ model: "gpt-image-1", prompt: "a cat" }), + config({ allowedProviders: ["some-other-provider"] }), + "generations", + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + const error = await denial(response); + expect(error.type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + // The caller learns which of its own requests was refused, nothing about the destination. + expect(error.model).toBe("gpt-image-1"); + expect(upstreamCalls).toEqual([]); +}); + +test("the same key still reaches the destination its scope names", async () => { + const response = await handleImages( + imagesRequest({ model: "gpt-image-1", prompt: "a cat" }), + config({ allowedProviders: ["openai-apikey"], allowedModels: ["gpt-image-1"] }), + "generations", + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); + expect(upstreamCalls[0]).toContain("/v1/images/generations"); +}); + +test("a body that names no model cannot satisfy a model list", async () => { + const response = await handleImages( + imagesRequest({ prompt: "a cat" }), + config({ allowedModels: ["gpt-image-1"] }), + "generations", + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect((await denial(response)).model).toBe(UNNAMED_DESTINATION_MODEL); + expect(upstreamCalls).toEqual([]); +}); + +test("naming the refusal marker in a scope grants nothing", async () => { + // The marker is how a refusal says "nobody named a model". An operator who + // copies it out of that refusal into allowedModels must not thereby allow + // whatever the upstream would have picked. + const response = await handleImages( + imagesRequest({ prompt: "a cat" }), + config({ allowedModels: [UNNAMED_DESTINATION_MODEL] }), + "generations", + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect(upstreamCalls).toEqual([]); +}); + +test("the xAI image bridge is a destination the scope covers", async () => { + const response = await handleImages( + imagesRequest({ model: "gpt-image-1", prompt: "a cat" }), + config({ allowedProviders: ["openai-apikey"] }, { + images: { bridgeEnabled: true }, + providers: { + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "fixture-xai-key", authMode: "key" }, + }, + } as Partial), + "generations", + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect((await denial(response)).type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + expect(upstreamCalls).toEqual([]); +}); + +test("Antigravity image generation is refused on its own resolved model", async () => { + const response = await handleImages( + imagesRequest({ model: "gpt-image-1", prompt: "a cat" }), + config({ allowedModels: ["gpt-image-1"] }, { + providers: { + "google-antigravity": { + adapter: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + }, + }, + } as Partial), + "generations", + logContext(), + undefined, + SCOPED, + ); + // The caller asked for an allowed selector; the branch resolves a different + // model on a different provider, which is the substitution the scope exists to catch. + expect(response.status).toBe(403); + expect(upstreamCalls).toEqual([]); +}); + +test("a key with no scope keeps reaching every image destination", async () => { + const response = await handleImages( + imagesRequest({ model: "gpt-image-1", prompt: "a cat" }), + config({ allowedProviders: ["some-other-provider"] }), + "generations", + logContext(), + undefined, + UNSCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); +}); diff --git a/tests/server/api-key-scope-live.test.ts b/tests/server/api-key-scope-live.test.ts new file mode 100644 index 00000000000..15919079867 --- /dev/null +++ b/tests/server/api-key-scope-live.test.ts @@ -0,0 +1,235 @@ +/** + * Per-key model and provider scope on the native voice relay (#5049). + * + * `/v1/live` and `/v1/realtime/calls` resolve an OpenAI upstream directly and + * relay the call-create body verbatim, so the model the caller states in its + * session — or in a standalone socket query — is the destination that gets + * billed. Nothing here passes through the router, so the scope has to be + * applied where that upstream is chosen. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MODEL_NOT_ALLOWED_FOR_KEY, UNNAMED_DESTINATION_MODEL } from "../../src/server/admission-model-scope"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import { LIVE_AUDIO_MODEL } from "../../src/server/audio-upstream"; +import { handleLive, resolveLiveSidebandUpgrade } from "../../src/server/live"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const SCOPED_KEY = "ocx_data_" + "l".repeat(40); +const OPEN_KEY = "ocx_data_" + "m".repeat(40); +const SCOPED: DataPlaneAdmission = { kind: "configured", keyId: "scoped", source: "dedicated" }; +const UNSCOPED: DataPlaneAdmission = { kind: "configured", keyId: "open", source: "dedicated" }; +const OTHER_LIVE_MODEL = "gpt-realtime-preview"; + +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; +let upstreamCalls: string[] = []; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-live-scope-")); + process.env.OPENCODEX_HOME = home; + upstreamCalls = []; + globalThis.fetch = (async (input: unknown) => { + upstreamCalls.push(String(input)); + return new Response("v=0", { status: 200, headers: { "content-type": "application/sdp" } }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (home) removeTreeWithRetry(home); + home = ""; +}); + +type Scope = { allowedProviders?: string[]; allowedModels?: string[] }; + +function config(scope: Scope): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai-apikey", + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + apiKey: "fixture-upstream-key", + authMode: "key", + }, + }, + apiKeys: [ + { id: "scoped", name: "mail", key: SCOPED_KEY, createdAt: "2026-01-01T00:00:00.000Z", ...scope }, + { id: "open", name: "coding", key: OPEN_KEY, createdAt: "2026-01-01T00:00:00.000Z" }, + ], + } as OcxConfig; +} + +function logContext(): RequestLogContext { + return { model: "gpt-live", provider: "unknown" } as RequestLogContext; +} + +/** A call-create the proxy admits by dedicated key, leaving Authorization for the upstream. */ +function jsonCallCreate(session?: Record, key = SCOPED_KEY): Request { + return new Request("http://localhost/v1/live", { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": key }, + body: JSON.stringify({ sdp: "v=0", ...(session ? { session } : {}) }), + }); +} + +function multipartCallCreate(session: Record): Request { + const form = new FormData(); + form.set("sdp", "v=0"); + form.set("session", JSON.stringify(session)); + return new Request("http://localhost/v1/realtime/calls", { + method: "POST", + headers: { "x-opencodex-api-key": SCOPED_KEY }, + body: form, + }); +} + +async function denial(response: Response): Promise<{ type: string; model: string }> { + const payload = await response.json() as { error: { type: string; model: string } }; + return payload.error; +} + +test("call-create is judged on the live model its session names", async () => { + const response = await handleLive( + jsonCallCreate({ model: OTHER_LIVE_MODEL }), + config({ allowedModels: [LIVE_AUDIO_MODEL] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + const error = await denial(response); + expect(error.type).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + expect(error.model).toBe(OTHER_LIVE_MODEL); + expect(upstreamCalls).toEqual([]); +}); + +test("a multipart call-create states its model in the session field", async () => { + const response = await handleLive( + multipartCallCreate({ model: OTHER_LIVE_MODEL }), + config({ allowedModels: [LIVE_AUDIO_MODEL] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect((await denial(response)).model).toBe(OTHER_LIVE_MODEL); + expect(upstreamCalls).toEqual([]); +}); + +test("a call-create that names no model has no destination a model list can allow", async () => { + const response = await handleLive( + jsonCallCreate(), + config({ allowedModels: ["something-else"] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect((await denial(response)).model).toBe(UNNAMED_DESTINATION_MODEL); + expect(upstreamCalls).toEqual([]); +}); + +test("a provider-only scope still creates a call that names no model", async () => { + const response = await handleLive( + jsonCallCreate(), + config({ allowedProviders: ["openai-apikey"] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); +}); + +test("a provider outside the scope cannot serve a voice call", async () => { + const response = await handleLive( + jsonCallCreate({ model: LIVE_AUDIO_MODEL }), + config({ allowedProviders: ["some-other-provider"] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(403); + expect(upstreamCalls).toEqual([]); +}); + +test("an allowed destination still reaches the upstream", async () => { + const response = await handleLive( + jsonCallCreate({ model: LIVE_AUDIO_MODEL }), + config({ allowedProviders: ["openai-apikey"], allowedModels: [LIVE_AUDIO_MODEL] }), + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); +}); + +test("a standalone realtime socket is judged on the model in its query", async () => { + const resolved = await resolveLiveSidebandUpgrade( + new Request("http://localhost/v1/realtime?model=" + OTHER_LIVE_MODEL, { + headers: { "x-opencodex-api-key": SCOPED_KEY }, + }), + config({ allowedModels: [LIVE_AUDIO_MODEL] }), + logContext(), + { style: "realtime-standalone", query: "model=" + OTHER_LIVE_MODEL }, + undefined, + SCOPED, + ); + expect(resolved).toBeInstanceOf(Response); + expect((resolved as Response).status).toBe(403); + expect((await denial(resolved as Response)).model).toBe(OTHER_LIVE_MODEL); +}); + +test("a native join names no model, so a model list cannot admit it", async () => { + // This compatibility path records nothing about the calls it relays, so the + // model a join attaches to is unknowable here. Admitting it against an + // assumed default would let a key scoped to that default ride a call created + // for another model. + const resolved = await resolveLiveSidebandUpgrade( + new Request("http://localhost/v1/live/call-abc", { headers: { "x-opencodex-api-key": SCOPED_KEY } }), + config({ allowedModels: [LIVE_AUDIO_MODEL] }), + logContext(), + { style: "frameless-path", callId: "call-abc" }, + undefined, + SCOPED, + ); + expect(resolved).toBeInstanceOf(Response); + expect((resolved as Response).status).toBe(403); +}); + +test("a provider-only scope still joins an existing call", async () => { + const resolved = await resolveLiveSidebandUpgrade( + new Request("http://localhost/v1/live/call-abc", { headers: { "x-opencodex-api-key": SCOPED_KEY } }), + config({ allowedProviders: ["openai-apikey"] }), + logContext(), + { style: "frameless-path", callId: "call-abc" }, + undefined, + SCOPED, + ); + expect(resolved).not.toBeInstanceOf(Response); + expect((resolved as { upstreamWsUrl: string }).upstreamWsUrl).toContain("call-abc"); +}); + +test("a key with no scope keeps the voice relay unrestricted", async () => { + const response = await handleLive( + jsonCallCreate({ model: OTHER_LIVE_MODEL }, OPEN_KEY), + config({ allowedModels: [LIVE_AUDIO_MODEL] }), + logContext(), + undefined, + UNSCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toHaveLength(1); +}); From 8d4acc805bcd2c22be2fda310e931edb7a484ba4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 20:02:04 +0900 Subject: [PATCH 16/87] Per-provider outbound egress: direct, inherit, HTTP(S) and SOCKS5 per provider (#5289) * feat(proxy): decide a provider's outbound egress per request The global `proxy` is one value for every upstream, so it cannot express the split #2894 describes: one gateway must exit through a regional proxy while another stays direct on the local network. `src/lib/provider-egress.ts` is the single authority that answers that question for one request, in the same shape #5087 established for the global decision -- the question is never "is a proxy configured" but "does a proxy apply to THIS request". Four states, resolved against the destination: absent inherit the global decision, byte-identical to today "direct" / null never use the global proxy for this provider http(s) URL this provider's own HTTP(S) proxy socks5(h) URL this provider's own SOCKS5 proxy `providers..noProxy` is applied to whichever route resolved, so it carves an exemption out of the provider's own proxy AND out of an inherited global one. That second case is how a provider exempts a single host without owning a proxy of its own. Two deliberate divergences from the issue's sketch. An empty string is rejected rather than read as a third spelling of DIRECT: a dashboard field the operator merely cleared must not silently switch a provider from inheriting the global proxy to refusing it. And a malformed value throws instead of degrading, because falling back to the global proxy would send a credential out a route nobody chose while falling back to direct would leave a restricted network with no exit -- both read as success at the call site. Direct egress is expressed to the runtime as `proxy: false`, which overrides HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY alike. `undefined`, `null` and `""` all mean "no option given" and fall back to the environment, so none of them can express it. `configuredOutboundFetch` had to learn the same distinction: reading `false` as "no string supplied" fell through to ALL_PROXY and sent a request pinned to direct egress through the global SOCKS proxy instead, which would have succeeded by the wrong exit. Configuration and request time share one definition through `providerEgressConfigError`, so a value the loader or the dashboard accepts is one the transport can carry. `proxy` is classified credential-bearing alongside `apiKey`: a proxy URL routinely embeds `user:password@`, so it never reaches the dashboard DTO, and nothing derived from it is logged -- not a hash, not a prefix, because a short digest over a known host is a guessable stand-in for the secret and a durable correlation key. Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com> * feat(proxy): apply the provider route to inference, discovery and quota Three transport owners now consume the decision instead of re-deriving it. Inference (`providerFetch`). The route is resolved per request rather than once per wrapper, because `noProxy` is evaluated against the destination and two sends through the same executor can legitimately take different exits. The resolved value reaches the dispatch init, so it survives `dispatchOverride` and the fresh-connection policy. Discovery and quota (`providerOutboundRequest`). This is the chokepoint every `providerOutboundGet`/`Post` caller shares -- provider discovery, the model-catalog gather, the management provider test and the Ollama show probe. A provider route replaces the global decision outright rather than combining with it: an explicit proxy applies even where global NO_PROXY exempts the host, because the operator named that proxy for that provider and `providers..noProxy` is the exemption belonging to that choice. A provider pinned to `direct` keeps the DNS-pinned transport, which reaches the peer through node:http and therefore needs nothing from the runtime's proxy handling -- the one path where direct egress is available by construction. An explicit proxy is pinned onto the request unconditionally, including through the DNS-failure degradation. Letting fetch re-infer the route there would move the request to a different exit at the exact moment local DNS stopped working, which is when the proxy matters most. Quota (`vendor-probes-key.ts`). Seventeen probes were bare global fetches, so a provider pinned to its own proxy still sent its quota probe by the process-wide route -- reporting a healthy account while inference failed, or sending the key out an exit the operator did not choose. Each probe already receives its provider config, so the route was available; only the transport was wrong. Where the route cannot be carried it is refused rather than dropped. A caller-supplied `provider.fetch` executor owns its own routing, so an explicit route throws instead of running the executor by a contradicting route. The WebSocket upstream selects its proxy from the process environment when it dials, so an explicit route serves those turns over HTTP/SSE and says so once per provider; a transport change nobody asked for is the same class of silent substitution this batch exists to remove. The regressions assert which transport carried each request and which proxy value it was pinned to. Asserting a 200 would pass with the route dropped entirely, which is the defect, not the fix. Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com> * docs(proxy): document per-provider egress and its uncovered surface The provider guide gains the two fields and a worked example matching the issue's real case. The transport inventory records, per request path, whether a provider route is honoured -- and where it is not, which is the part that matters: OAuth token exchange and refresh, the OAuth-backed quota probes and the API-key validation probes all reach fixed vendor endpoints from modules that hold no provider config, so a provider pinned to its own proxy still refreshes credentials by the process-wide route. Cursor's HTTP/2 transport, the coding-agent subprocess providers and the Lab pinned sender are recorded for the same reason. The lane document records the egress work, the disposition for the CodeBuddy and native-wire bundle, and the union-defect sweep. * fix(proxy): resolve the provider route at the physical send Adversarial review of the branch found three defects in the first pass. The route was resolved when the fetch wrapper was built, but a `dispatchOverride` can rebuild a queued request against a different upstream host before it leaves -- account reselection moves the regional host for Copilot, and Anthropic pool rotation rebuilds the request entirely. The original `dispatchInit` was reused with its now-stale proxy value, so a host-scoped `noProxy` decision could be inverted and a bearer could leave by a route the operator excluded. The decision now sits in `sendWithConnectionPolicy`, against the destination actually being sent to and around whichever executor was just selected. That is the same boundary and the same reason as #4992, which that function's own comment already records for the connection policy. Refusing every `provider.fetch` as transport-owning was too broad. The xAI route installs a wrapper on every request that only adds a generated request id and forwards the init, so an explicit route would have thrown for xAI -- one of the two providers #2894 names. Executors that forward their init are now marked transparent and carry the route; the mark is opt-in, so an executor arriving from configuration stays opaque and is still refused. The executor `providerFetch` returns is marked too, because Cursor hands it back as `provider.fetch`. xAI's default executor also fell back to the bare global fetch, which ignores a socks5 value. A per-provider SOCKS5 route would have sent the request unproxied while the configuration named a proxy. It now routes through `configuredOutboundFetch` like every other default. Two smaller ones: the WebSocket downgrade notice logged a configuration-controlled provider name unredacted, which this repository treats as potentially token-shaped everywhere else, and its notice set had no bound. * fix(proxy): bind the route on native Chat sends and refuse before dispatch Two more defects from review of the previous commit. Native Chat builds its own physical send and calls the connection policy with `activeProvider.fetch ?? execute`. A provider transport wins over the executor that carries the egress binding, so that send omitted the route entirely -- and since the xAI route now always installs a transport, xAI native Chat would have followed global routing while its configuration named a proxy, and an opaque executor would have been invoked instead of refused. The binding now travels with that send, resolved against the provider the send actually uses, which matters because reselection can replace it mid-dispatch. Moving the refusal to the physical send also moved it after `options.beforeDispatch`, which commits attempt accounting and consumes admission state. A refusal firing after it would charge an attempt for a send that never happens, and a throwing hook would mask the egress error with an unrelated one. The wrapper now fails fast before the hook; the authoritative decision still happens at the send, against the destination that send uses. * fix(proxy): decide the route once at the outermost physical boundary A third review round found that the executor `providerFetch` hands to a `dispatchOverride` was not marked transparent. Every override selects `provider.fetch ?? execute`, so for an ordinary provider with no custom transport that executor IS the selected one -- and an explicit route would have been refused on every overridden path, after the attempt had already been recorded by `commitKeyAttemptSend` or `noteProviderAttemptSend`. Only xAI escaped it, because its own wrapper carries the mark. None of the existing regressions covered the production-shaped nested send, so two now do. Marking it alone would have been wrong in the other direction: these calls nest, and the inner pass would have recomputed the route from the closure's provider after the override had already decided with the reselected one. The outermost boundary now decides and marks the init; the inner pass honours the mark. An override that simply calls the executor still gets a decision rather than losing the route. The pre-dispatch fast fail is narrowed to match. With no override, the input and executor at that point are the final ones, so the full decision is made before `beforeDispatch`. With an override, only the configured value is validated, because refusing against a destination the override is about to replace would reject a request whose real route is fine. A refusal caused by `noProxy` now names `noProxy` rather than telling the operator to remove a `proxy` override they never wrote. The provider guide gained the coverage limits it was missing -- it described the three states without saying which transports cannot carry them. * fix(proxy): give the provider egress config fields a declared output type The two zod field schemas used `z.unknown().superRefine(...)` so the shared resolver could produce the message, but never narrowed the result. That makes the parsed provider record carry `proxy: unknown` and `noProxy: unknown`, which is not assignable to `OcxProviderConfig` -- four errors in `config-schema.ts`, and a typecheck-based adapter contract test that asserts zero errors reported one. Both CI failures had this single cause. They now transform to their declared types, matching the superRefine-plus-transform idiom the neighbouring field schemas already use. Validation is unchanged and still delegates to `providerEgressConfigError`, so configuration and request time keep one definition of a usable value. The fetch-helpers import boundary test pins the exact runtime-import list for that file; it gains the two modules this lane added. * test(proxy): keep credentialed proxy fixtures off the email pattern The privacy scan reads a URL userinfo pair as an address: `user:pw@host.tld` looks exactly like `pw@host.tld`. Three fixtures that deliberately carry a credential to prove it never reaches a log or an error tripped it. They move to a `.test` host, which the scanner already allows for fixtures and which the repository uses elsewhere for the same reason. The assertions are unchanged: the credential must still not appear in the sanitized label, the described route, or the validation error. * docs(devlog): record the lane F outcome and the defects each gate caught Names the exact-head CI evidence, the three route-seam defects adversarial review caught before CI ran, and the two CI caught after review had cleared them. * docs(devlog): describe the credential-fixture defect without reproducing it The lane document explained why the privacy scan rejected the credentialed proxy fixtures by quoting the shape that triggered it, which tripped the same scan on the document. It now describes the shape instead of writing one. --------- Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com> --- .../060_lane_f.md | 246 ++++++++++++++ .../docs/reference/configuration/providers.md | 62 ++++ scripts/test-layout/layout.json | 4 + src/config/schema/leaf-validators.ts | 24 ++ src/lib/provider-egress.ts | 310 ++++++++++++++++++ src/lib/provider-outbound.ts | 49 ++- src/lib/proxy-env.ts | 26 +- src/providers/quota/vendor-probes-key.ts | 59 ++-- src/providers/xai-transport.ts | 13 +- src/server/auth-cors.ts | 15 + src/server/chat-native.ts | 5 + src/server/responses/fetch-helpers.ts | 121 ++++++- src/server/responses/request-transport.ts | 11 +- src/types/provider.ts | 29 ++ structure/config.md | 1 + structure/transports/inventory.md | 26 ++ tests/fixtures/test-layout-expected.json | 4 + tests/lib/provider-egress.test.ts | 183 +++++++++++ .../provider-egress-outbound.test.ts | 247 ++++++++++++++ tests/responses/provider-egress-fetch.test.ts | 261 +++++++++++++++ .../responses-fetch-helpers-boundary.test.ts | 2 + ...vider-egress-management-validation.test.ts | 49 +++ 22 files changed, 1705 insertions(+), 42 deletions(-) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/060_lane_f.md create mode 100644 src/lib/provider-egress.ts create mode 100644 tests/lib/provider-egress.test.ts create mode 100644 tests/providers/provider-egress-outbound.test.ts create mode 100644 tests/responses/provider-egress-fetch.test.ts create mode 100644 tests/server/provider-egress-management-validation.test.ts diff --git a/devlog/_plan/260920_meaning_preservation_batch/060_lane_f.md b/devlog/_plan/260920_meaning_preservation_batch/060_lane_f.md new file mode 100644 index 00000000000..e51bbedfc47 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/060_lane_f.md @@ -0,0 +1,246 @@ +# Lane F — per-provider egress, and the CodeBuddy/native-wire disposition + +Status: OPEN. PR #5289 against `dev` at exact head `40fe2ee7d8`, hosted CI green across the +whole matrix. One branch, ordered commits, one pull request. Covers phase 2 bundles 13 and 15 +from [010_phase2.md](010_phase2.md). + +Lane F was scheduled after lane B because bundle 13 consumes the request-scoped route decision +that lane B built for #5087. That landed as #5264 (`8e1fdea1`), so this lane reads +`effectiveProxyFor` as the authority for the global decision and does not restate it. + +## 13 — per-provider egress + +### What the bundle actually was + +Issue #2894 asks for two things and only one of them was missing. Global SOCKS5 already ships: +`socks5ProxyFromEnv`, `socks5Fetch` and the `configureSocks5Fetch` wrapper handle it, and +`applyProxyEnv` mirrors a configured value into `ALL_PROXY`. Rebuilding that was never in +scope. What was missing is the two-level model: a per-provider override with direct / inherit / +custom, so one upstream can exit through a regional proxy while another stays direct. + +### The decision function + +`src/lib/provider-egress.ts` resolves one route for one destination. It deliberately mirrors +#5087's shape: the question is never "is a proxy configured" but "does a proxy apply to THIS +request". Four states — inherit, direct, http(s) proxy, socks5 proxy — with +`providers..noProxy` applied to whichever route resolved, which is what lets a provider +exempt one destination from an inherited global proxy without owning a proxy of its own. + +Two divergences from the issue's sketch, both deliberate: + +- **An empty string is rejected, not read as DIRECT.** The issue lists `""` as a third spelling + of direct. A dashboard field the operator merely cleared would then silently switch a provider + from inheriting the global proxy to refusing it. The error names both real alternatives. +- **A malformed value throws rather than degrading.** Falling back to the global proxy sends a + credential out a route nobody chose; falling back to direct leaves a restricted network with + no exit. Both read as success at the call site, which is the defect class this batch exists + to remove. + +### How direct egress is expressed, and why that needed settling + +This was the one genuine unknown. #3901 refused the direct state outright with the message +"direct has no safe request-scoped transport on this runtime". That is correct about the +mechanism it rejected and wrong as a general claim. + +Bun's documented `proxy: false` connects directly regardless of `HTTP_PROXY`, `HTTPS_PROXY`, +`ALL_PROXY` **and** `NO_PROXY`. The same documentation states that `undefined`, `null` and +`""` all mean "no option given" and fall through to the environment, so none of them can +express direct egress — which is why the resolver emits the literal `false` and never an empty +string. + +`configuredOutboundFetch` had to learn the same distinction. It derived its SOCKS route with +`typeof explicitProxy === "string" ? … : socks5ProxyFromEnv()`, so a `false` fell into the +environment branch and a request pinned to direct egress would have been sent through the global +SOCKS proxy. It would have returned 200 by the wrong exit, which no status-code assertion can +see. That is now a regression. + +One path needs nothing from the runtime at all: on `providerOutboundRequest`, direct egress is +the DNS-pinned transport, which connects through `node:http` to an address this process +resolved and never reads the proxy environment. Discovery and quota therefore have direct +egress by construction rather than by flag. + +### Reach, stated as coverage rather than implied + +Honoured: the main inference dispatch (`providerFetch`), every +`providerOutboundGet`/`providerOutboundPost` caller (provider discovery, the model-catalog +gather, the management provider test, the Ollama show probe), and the seventeen API-key quota +probes in `vendor-probes-key.ts`. + +Refused rather than dropped: a caller-supplied `provider.fetch` executor owns its own routing, +so an explicit route throws instead of running the executor by a contradicting route. The +WebSocket upstream picks its proxy from the process environment when it dials, so an explicit +route serves those turns over HTTP/SSE and says so once per provider. + +**Not covered, and this is the honest limit of the change:** OAuth token exchange and refresh +under `src/oauth/`, the OAuth-backed quota probes in `vendor-probes-oauth.ts`, and the API-key +validation probes in `key-providers.ts`. All three reach fixed vendor endpoints from modules +that hold no provider config, and `validateApiKey` receives a derived `KeyLoginProvider` whose +caller builds the real provider record only afterwards. Threading provider config through those +call sites is a caller-contract change across roughly a dozen OAuth modules and is not attempted +here. The consequence is stated plainly in the provider guide and the transport inventory: a +provider pinned to its own proxy or to direct still refreshes credentials by the process-wide +route. #2894 therefore stays open for that half. + +Also uncovered and recorded: Cursor's default HTTP/2 transport, the coding-agent subprocess +providers whose scoped child environment omits proxy variables, and the Compatibility Lab pinned +sender. + +### Overlap with the #5049 router-bypass list + +Lane E recorded authenticated data-plane endpoints that spend provider quota without resolving a +model through the router. Every one of them is also outside this lane's egress reach, for the +same structural reason — no routed provider at the send — and the overlap is complete: +`/v1/images/generations`, `/v1/images/edits`, `/v1/audio/transcriptions` and its streaming +form, `/v1/live`, `/v1/realtime/calls`, the standalone realtime sockets, and the +non-account-qualified branch of `/v1/alpha/search`. + +### Credential handling + +A proxy URL routinely embeds `user:password@`. `proxy` is classified credential-bearing +alongside `apiKey`, so it never reaches the dashboard DTO and the editor may not write it; +`ocx config set` and the config file remain the way to set it. Log output keeps scheme, host +and port only. Nothing derived from the credential is emitted — the carried +`providerEgressRouteKey` FNV-1a digest over the full proxy URL was dropped rather than carried, +because a 32-bit digest over a known host is a guessable stand-in for the secret and a durable +correlation key for the account behind it, and it had no consumer. + +### A finding recorded rather than acted on + +Bun's documentation states it uses `ALL_PROXY` for `http:` and `https:` alike when the +scheme-specific variable is unset. `effectiveProxyFor` counts a non-SOCKS `ALL_PROXY` only for +`http:` targets. The divergence fails toward keeping the DNS-pinned transport, which is the safe +direction, and lane B reasoned about and tested that boundary explicitly. Changing it is lane +B's surface, not this one, so it is recorded here rather than altered. + +### Carried work + +#3901 (jingzxy) — per-provider HTTP proxy overrides. Carried with a `Co-authored-by` trailer on +both code commits. The branch was 289 `dev` commits behind and its `provider-outbound.ts` hunks +were written against the pre-#5264 `outboundProxyConfigured` shape, so the work was carried onto +the landed decision rather than replayed. Its management cases would also have pushed +`tests/server/management-provider-validation.test.ts` from 5,498 to 5,612 lines against a 5,506 +cap; those cases live in a registered sibling file instead. The original pull request stays open +for the coordinator. + +## 15 — CodeBuddy tool bridge and native wire: disposition + +Item 15 is delivered as a disposition, not an implementation. All three pull requests were +audited against current `dev` and none is carryable as it stands. Recording why is the +deliverable; carrying a defect with a `Co-authored-by` trailer on it would not be. + +No issue is closed by this lane. #5146, #5097 and #5096 stay open. + +### #5147 — account-roster discovery: blocked on an attribution defect + +The roster is read by running the vendor CLI, which answers for the account **signed in to that +CLI's home directory**. The result is then cached under a fingerprint of the **configured API +key**. Those are two different identities. The fingerprint isolates cache reuse between +configured keys, which is what it was designed for, but it does not make the roster belong to +the key it is filed under: with key B configured and account A signed in to the CLI, the proxy +advertises A's models as B's catalog. That is the same class of defect bundle 8 is about — an +observation outliving the identity it was made under — so carrying it into this batch would +contradict the batch. + +The rest of the pull request reviewed clean: the key is passed by environment rather than argv, +output is bounded at 512 KiB with an 8-second timeout, an explicit `liveModels: false` is +preserved, and a missing CLI warns and degrades to the static seed rather than crashing. The +defect is the binding, not the plumbing. + +### #5148 — capture-only tool bridge: conflicts, and coverage short of the bar + +The capture-only security boundary itself reviewed sound: the MCP server advertises and captures +but never resolves a call, no path traversal or execution route was found, and no secret reaches +the logs. The CLI does not execute tools and client approval is preserved. + +It does not apply to current `dev` — `src/adapters/coding-agent/turn.ts` conflicts and seven +touched files drifted since its merge base. More important for this batch, its tests do not +reach the acceptance bar set for item 15. Directly uncovered: a **successful** multi-call +assistant message, call-ID preservation across the capture boundary, bridge-specific reasoning +replay on the continuation turn, and an integrated abort that proves process-tree cleanup rather +than orphaning a child. Those four are exactly the cases a "the first tool call worked" test +cannot see, which is why they were named as the completion condition. + +Landing it would mean rebasing the adapter work and writing those four regressions. That is a +lane of its own, not a trailing commit on this one. + +### #5188 — Alibaba Token Plan default flip: evidence does not support it + +#5198 already landed the opt-in and declined the flip, and added a guard that fails if a +Responses wire default is declared for this entry without `preserveResponsesReasoningContent` +beside it. #5188 proposes exactly that declaration without that flag. + +The guard is not bureaucratic. The entry sets `preserveReasoningContentModels`, which the +**Chat** adapter reads; the Responses serializer reads a different flag this entry does not set, +so pinned models would replay continuations with blanked reasoning content — strictly less state +than they carry today. Z.AI and DeepSeek set both flags together and their entry comments say +why. + +The live evidence in #5097 covers a tool call and a continuation that replays +`custom_tool_call` and `custom_tool_call_output`. It does not assert that reasoning content +survived that continuation, which is the one thing the flip would change. The delegation's own +constraint applies: do not change inbound behaviour or international endpoints without evidence. +The opt-in stands; the flip waits for a replay that demonstrates reasoning preservation. + +## Verification + +Static source review plus exact-head hosted CI. No local suite, individual test, typecheck, +build, install, live `ocx` execution or service restart was run — those are **NOT RUN**, not +passing. + +Hosted CI at `40fe2ee7d8` is green: all four test shards, both macOS halves, `gates` +(typecheck, GUI tests, privacy scan, generated skill surface), the structure gate, docker smoke, +storage policy, api usage, the three `npm-global` smokes and the three keyring jobs. + +### What only CI could tell me, and what only review could + +Three defects reached a pushed head and were caught by adversarial review before CI ran, all in +the same seam and all invisible to a status-code assertion: + +1. The route was resolved when the fetch wrapper was built, but `dispatchOverride` can rebuild a + queued request against a different upstream host. A host-scoped `noProxy` decision could + therefore be applied to a host it was not decided for, sending a bearer out an excluded route. + The decision moved to `sendWithConnectionPolicy` — the same boundary and the same reason + #4992 records for the connection policy. +2. Refusing every `provider.fetch` as transport-owning was too broad. The xAI route installs a + wrapper on every request that only adds a generated request id, so an explicit route would + have thrown for one of the two providers #2894 names. +3. The executor handed to an override was itself unmarked, so an ordinary provider would have + been refused on every overridden path — after the attempt had already been recorded. None of + the regressions written to that point covered the production-shaped nested send; two do now. + +CI then found two more that review had cleared. The zod field schemas used +`z.unknown().superRefine(...)` without narrowing, so the parsed provider record carried +`proxy: unknown` and failed to satisfy `OcxProviderConfig` — four typecheck errors, and a +typecheck-based adapter contract test that asserts zero errors reported one. And the privacy +scan reads a URL userinfo pair as an address, so the fixtures that deliberately carry a +credential to prove it never reaches a log were read as one. They moved to the `.test` host the +scanner already allows for fixtures, with the assertions unchanged. Both are the reason this +lane treats hosted CI as the verification and static review as the preparation for it, rather +than the reverse — and the second one repeated itself in this very document, which first +described the defect by quoting the shape that caused it. + +Union-defect sweep before pushing: + +- **File-size ratchet.** No touched source file carries a cap. + `tests/server/management-provider-validation.test.ts` does (5,506) and is deliberately not + touched; the management egress cases are a registered sibling file. +- **Exhaustive over a union.** Adding `proxy` and `noProxy` to `OcxProviderConfig` makes + `PROVIDER_CONFIG_FIELD_POLICY` — declared `satisfies Record` — + fail to compile until both are classified. Both are, and the classification is asserted rather + than assumed. +- **Derived, not restated.** The tests import `PROVIDER_EGRESS_DIRECT`, + `MIN_BOUNDED_CODEX_WS_BUN_VERSION` and `CODEX_RESPONSES_HTTP_URL` from source instead of + repeating their values, and configuration validation calls the resolver instead of restating + what a valid proxy value is. No count in generated documentation was touched. +- **Test layout.** Four new test files, each registered in both `scripts/test-layout/layout.json` + and `tests/fixtures/test-layout-expected.json`. +- **Exact-list guards.** `tests/responses/responses-fetch-helpers-boundary.test.ts` pins the + runtime-import list of `fetch-helpers.ts` and needed the two modules this lane adds. It is the + restatement class in miniature, and it is the guard working as intended: the list is a + deliberate classification, so adding to it is a reviewed decision rather than a silent one. + +## Ownership + +Consumed and not redefined: lane C's send accounting, lane E's adapter event queue budget and +per-key model/provider scope (`src/server/admission-model-scope.ts`), and lane D's per-model +cache views. The `effectiveProxyFor` global decision belongs to lane B and is read, not changed. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 445e99df483..c3eb866315b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -141,6 +141,8 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | --- | --- | --- | | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | +| `proxy?` | `string \| null` | Per-provider egress route. Omit it to inherit the global proxy decision; use `"direct"` or `null` to force direct egress; or provide an absolute `http://`, `https://`, `socks5://`, or `socks5h://` proxy URL. An empty string is rejected. | +| `noProxy?` | `string \| string[]` | Destinations this provider reaches directly, using `NO_PROXY` host-pattern syntax. A match bypasses both this provider's own proxy and an inherited global proxy. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | @@ -248,6 +250,66 @@ nonempty incompatible list falls back to the native default as a single choice. belong to the final list. This changes the catalog projection, not stored configuration. See [custom native catalog examples](/guides/codex-app-models/). +### Per-provider egress + +Set `proxy` on a provider when that upstream needs a different exit from the process-wide proxy: + +- Omit `proxy` to inherit the global proxy and `NO_PROXY` decision. +- Set `proxy` to `"direct"` or `null` to force this provider to connect directly, even when a global proxy is set. +- Set `proxy` to an absolute `http://` or `https://` URL to use that HTTP proxy for this provider. +- Set `proxy` to an absolute `socks5://` or `socks5h://` URL to use that SOCKS5 proxy for this provider. + +An empty or whitespace-only string is rejected on purpose. A cleared field must not silently change +from “inherit the global proxy” to “force direct”; remove the field to inherit, or write `"direct"` +to choose direct egress explicitly. + +`noProxy` accepts a comma-separated string or an array of strings in `NO_PROXY` syntax. It is +evaluated for each request. A matching destination goes direct whether the provider would otherwise +use its own `proxy` or inherit a global proxy. + +#### What the route covers + +The route is applied to routed inference, provider discovery and connection tests, and API-key +quota probes. Some transports cannot carry it, and OpenCodex says so rather than pretending +otherwise: + +- **OAuth token exchange and refresh** keep using the process-wide proxy. These reach fixed vendor + endpoints from code that holds no provider configuration, so a provider pinned to its own proxy + or to `"direct"` still refreshes its credentials by the global route. OAuth-backed quota probes + and API-key validation probes behave the same way. +- **The Responses WebSocket fast lane** selects its proxy when it dials and cannot carry a + per-provider route, so a provider that declares one serves those turns over HTTP/SSE instead and + logs a one-time notice. +- **Cursor's default HTTP/2 transport**, the **CodeBuddy and Qoder subprocess providers** (their + child environment omits proxy variables), and the **Compatibility Lab** pinned sender do not + apply it. +- Endpoints that do not route a model — image generation and edits, audio transcription, live and + realtime calls, and unqualified `/v1/alpha/search` — have no provider route to apply. + +A provider configured with a custom `fetch` executor is refused rather than silently sent by the +executor's own route. + +This example keeps a global proxy for ordinary traffic, sends one provider through a regional HTTP +proxy, and pins another provider to a direct connection: + +```json +{ + "proxy": "http://global-proxy.example:8080", + "providers": { + "regional-gateway": { + "adapter": "openai-chat", + "baseUrl": "https://regional-api.example/v1", + "proxy": "http://regional-proxy.example:3128" + }, + "direct-gateway": { + "adapter": "openai-chat", + "baseUrl": "https://direct-api.example/v1", + "proxy": "direct" + } + } +} +``` + ### Operator-pinned reasoning effort Set `pinnedReasoningEffort` on an existing provider to override incoming effort choices, or diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 943a04250e0..f9d1fe63007 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -172,6 +172,10 @@ "provider-send-path-import.test.ts": "server", "socks5-fetch.test.ts": "lib", "socks5-upload-lifecycle.test.ts": "lib", + "provider-egress.test.ts": "lib", + "provider-egress-outbound.test.ts": "providers", + "provider-egress-fetch.test.ts": "responses", + "provider-egress-management-validation.test.ts": "server", "start-args.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index abf7e5b0bb7..0906b75671e 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -17,6 +17,7 @@ import { isCodexAccountPriorityKey } from "../../codex/account-priority"; import { parseAccountPriority } from "../../codex/pool-rotation"; import { credentialGroupIssues } from "../../routing/identity-domains"; import { providerDestinationConfigError } from "../../lib/destination-policy"; +import { providerEgressConfigError } from "../../lib/provider-egress"; import { redactSecretString } from "../../lib/redact"; import { MODEL_ADAPTER_OVERRIDE_ALLOWED, @@ -221,6 +222,25 @@ const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => { if (error) ctx.addIssue({ code: "custom", message: error }); }).transform(value => mergeModelCapabilities(undefined, value)); +/** + * Per-provider egress fields, validated by the resolver the transports themselves use. + * + * Calling `providerEgressConfigError` rather than restating the accepted forms keeps one + * definition of a usable value: a proxy the config loader admits is one the transport can + * carry, and a value rejected here is rejected at request time for the identical reason. + * Each field is checked on its own because neither depends on the other's value to be + * well-formed; how they combine is decided per request against the destination. + */ +const providerProxySchema = z.unknown().superRefine((value, ctx) => { + const error = providerEgressConfigError({ proxy: value as string | null | undefined }); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => value as string | null | undefined); + +const providerNoProxySchema = z.unknown().superRefine((value, ctx) => { + const error = providerEgressConfigError({ noProxy: value as string | string[] | undefined }); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => value as string | string[] | undefined); + /** * Zod schema for one provider entry: known fields are validated strictly while unknown * fields pass through (preserved for runtime extensions). @@ -269,6 +289,10 @@ export const providerConfigSchema = z.object({ decodesNativeCompactionBlobs: z.boolean().optional(), allowEncryptedV2AgentTasks: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), + // Per-provider egress (#2894): absent inherits the global proxy decision, "direct"/null + // refuses it, and an http(s) or socks5 URL replaces it for this provider only. + proxy: providerProxySchema.optional(), + noProxy: providerNoProxySchema.optional(), // The management API accepts `null` as "clear this", so a config written before the POST // canonicalization below can hold one on disk. Rejecting it here would send the operator // through invalid-config recovery for a value the API told them was fine. diff --git a/src/lib/provider-egress.ts b/src/lib/provider-egress.ts new file mode 100644 index 00000000000..f37fefd9bd8 --- /dev/null +++ b/src/lib/provider-egress.ts @@ -0,0 +1,310 @@ +/** + * Per-provider egress: which transport a request for THIS provider actually leaves by. + * + * The global `proxy`/`noProxy` pair is process-wide (mirrored into HTTP_PROXY/HTTPS_PROXY/ + * ALL_PROXY/NO_PROXY by `applyProxyEnv`), so it cannot express the split #2894 describes: + * one upstream must exit through a regional proxy, another must stay direct on the local + * network. This module is the single authority that answers that question for one request, + * and every transport owner that can carry the answer consumes it rather than re-deriving it. + * + * It deliberately mirrors the shape #5087 established for the global decision in + * `effectiveProxyFor`: the question is never "is a proxy configured" but "does a proxy apply + * to THIS request". A provider route is resolved against the request URL, so a per-provider + * bypass list is part of the decision rather than a second check somewhere downstream. + * + * The three states are exactly the ones the issue asks for, with one deliberate divergence: + * + * - field absent -> `inherit`: the global decision stands, byte-identical to today; + * - `null` or `"direct"` -> `direct`: this provider never uses the global proxy; + * - an http(s) URL -> `proxy`: this provider uses its own HTTP(S) proxy; + * - a socks5(h) URL -> `proxy`: this provider uses its own SOCKS5 proxy. + * + * The divergence is the empty string. #2894 sketches `""` as a third spelling of DIRECT. + * Treating it that way would make a dashboard field the operator merely cleared silently + * change a provider from "inherit the global proxy" to "never use the global proxy" — the + * quiet reinterpretation this batch exists to remove. An empty or whitespace-only value is + * therefore a configuration error naming both real alternatives. + */ +import type { OcxProviderConfig } from "../types"; +import { isSocks5ProxyUrl, noProxyMatches } from "./proxy-env"; + +export class InvalidProviderEgressError extends Error { + override readonly name = "InvalidProviderEgressError"; + constructor( + /** The provider field that carries the offending value. */ + readonly field: "proxy" | "noProxy", + /** The failure on its own, so configuration surfaces can phrase it their own way. */ + readonly reason: string, + message: string, + ) { + super(message); + } +} + +/** The literal an operator writes to pin one provider to direct egress. */ +export const PROVIDER_EGRESS_DIRECT = "direct"; + +export type ProviderEgress = + | { kind: "inherit" } + | { kind: "direct"; reason: "configured" | "noProxy" } + | { kind: "proxy"; proxyUrl: string; transport: "http" | "socks5" }; + +export interface ProviderEgressContext { + providerName: string; + provider: Pick; + url: string | URL; +} + +function egressFailure(providerName: string, field: "proxy" | "noProxy", reason: string): never { + throw new InvalidProviderEgressError(field, reason, `providers.${providerName}.${field} is invalid: ${reason}`); +} + +/** + * A proxy URL reduced to scheme, host and port for operator-facing output. + * + * A proxy URL routinely carries `user:password@`, and this value reaches startup banners, + * diagnostics and the dashboard DTO. `URL.origin` drops userinfo, query and path, so what is + * left identifies the route without reproducing the credential. Nothing derived from the + * credential is emitted either — not a hash, not a prefix — because a short digest over a + * known host is a guessable stand-in for the secret and a durable correlation key for the + * account behind it. + */ +export function sanitizeProxyUrlForLog(proxyUrl: string): string { + try { + const parsed = new URL(proxyUrl); + return parsed.port ? `${parsed.protocol}//${parsed.hostname}:${parsed.port}` : parsed.origin; + } catch { + return ""; + } +} + +export function describeProviderEgressForLog(egress: ProviderEgress): string { + if (egress.kind === "inherit") return "inherit"; + if (egress.kind === "direct") return `direct(${egress.reason})`; + return `${egress.transport}(${sanitizeProxyUrlForLog(egress.proxyUrl)})`; +} + +function parseTargetUrl(providerName: string, url: string | URL): URL { + if (url instanceof URL) return url; + try { + return new URL(url); + } catch { + return egressFailure(providerName, "proxy", "the request URL is not parseable, so no provider route can be decided for it"); + } +} + +function normalizeNoProxy(providerName: string, raw: string | string[] | undefined): string | null { + if (raw === undefined) return null; + const entries = Array.isArray(raw) ? raw : [raw]; + for (const entry of entries) { + if (typeof entry !== "string") { + return egressFailure(providerName, "noProxy", "every entry must be a string host pattern"); + } + } + const joined = entries.join(",").trim(); + return joined.length > 0 ? joined : null; +} + +function parseProviderProxyRoute(providerName: string, raw: string): ProviderEgress { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return egressFailure( + providerName, + "proxy", + `an empty value is ambiguous; write "${PROVIDER_EGRESS_DIRECT}" to force direct egress, or remove the field to inherit the global proxy`, + ); + } + if (trimmed.toLowerCase() === PROVIDER_EGRESS_DIRECT) return { kind: "direct", reason: "configured" }; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return egressFailure( + providerName, + "proxy", + `"${PROVIDER_EGRESS_DIRECT}" or an absolute proxy URL is required; this value is neither`, + ); + } + if (isSocks5ProxyUrl(trimmed)) { + if (!parsed.hostname) { + return egressFailure(providerName, "proxy", "the SOCKS5 proxy URL has no host"); + } + return { kind: "proxy", proxyUrl: trimmed, transport: "socks5" }; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return egressFailure( + providerName, + "proxy", + `unsupported proxy scheme "${parsed.protocol}"; supported schemes are http, https, socks5 and socks5h`, + ); + } + if (!parsed.hostname) { + return egressFailure(providerName, "proxy", "the proxy URL has no host"); + } + return { kind: "proxy", proxyUrl: parsed.toString(), transport: "http" }; +} + +/** + * The route this provider's request leaves by, or `inherit` when the global decision stands. + * + * Throws `InvalidProviderEgressError` rather than degrading to `inherit`: a malformed egress + * field is the one case where guessing is worst. Falling back to the global proxy would send a + * credential through a route the operator did not choose, and falling back to direct would + * leave a restricted network with no exit. Both read as success at the call site. + * + * A per-provider `noProxy` match outranks the provider's own proxy for the same reason it + * outranks the global one: it names destinations this provider must reach without a proxy. + * It is evaluated against the resolved route, so it also carves holes in an inherited global + * proxy — which is how a provider exempts one host without owning a proxy of its own. + */ +export function resolveProviderEgress(context: ProviderEgressContext): ProviderEgress { + const { providerName, provider } = context; + const raw = provider.proxy; + let route: ProviderEgress; + if (raw === undefined) { + route = { kind: "inherit" }; + } else if (raw === null) { + route = { kind: "direct", reason: "configured" }; + } else if (typeof raw !== "string") { + return egressFailure(providerName, "proxy", "the value must be a proxy URL string, \"direct\", null, or absent"); + } else { + route = parseProviderProxyRoute(providerName, raw); + } + const noProxy = normalizeNoProxy(providerName, provider.noProxy); + if (noProxy !== null) { + const target = parseTargetUrl(providerName, context.url); + if (noProxyMatches(target, { NO_PROXY: noProxy })) return { kind: "direct", reason: "noProxy" }; + } + return route; +} + +/** + * Whether this provider decided the route itself, as opposed to deferring to the global one. + * + * Transport owners use this to tell "the operator chose this" from "nothing was configured", + * which are the two cases that must not be collapsed when a transport cannot carry the choice. + */ +export function providerEgressIsExplicit(egress: ProviderEgress): boolean { + return egress.kind !== "inherit"; +} + +/** + * The request-scoped fetch options that express `egress` to Bun's fetch. + * + * `proxy: false` is Bun's documented per-request direct connection: it ignores HTTP_PROXY, + * HTTPS_PROXY and ALL_PROXY, and it ignores NO_PROXY as well, which is what makes it a + * decision rather than a hint. `undefined`, `null` and `""` all mean "no option given" to + * Bun and fall through to the environment, so none of them can express direct egress — the + * reason this returns the literal `false` and never an empty string. + * + * A SOCKS5 route is returned as the same `proxy` string; `configuredOutboundFetch` recognises + * the scheme and hands the request to the SOCKS transport, because Bun's own fetch ignores a + * socks5 value. + */ +export function providerEgressFetchInit(egress: ProviderEgress): { proxy?: string | false } { + if (egress.kind === "inherit") return {}; + if (egress.kind === "direct") return { proxy: false }; + return { proxy: egress.proxyUrl }; +} + +/** + * Marker for an executor that forwards its `RequestInit` to a transport which honours the + * request-scoped proxy option. + * + * A provider route is refused on an executor that owns its own transport, because applying it + * is impossible and ignoring it is worse. But not every `provider.fetch` owns a transport: + * some are internal wrappers that add a header and delegate, and `src/providers/xai-transport.ts` + * installs exactly such a wrapper on every xAI route. Refusing those would make the per-provider + * proxy unusable on one of the two providers the original issue names. + * + * The marker is opt-in and applied by the wrapper's author, so an executor that arrives from + * configuration or from a caller is opaque by default and still refused. `Symbol.for` keeps the + * mark readable across duplicated module instances. + */ +const EGRESS_TRANSPARENT_EXECUTOR = Symbol.for("opencodex.provider-egress.transparent-executor"); + +export function markEgressTransparentExecutor(executor: Fetch): Fetch { + (executor as unknown as Record)[EGRESS_TRANSPARENT_EXECUTOR] = true; + return executor; +} + +export function isEgressTransparentExecutor(executor: unknown): boolean { + return typeof executor === "function" + && (executor as unknown as Record)[EGRESS_TRANSPARENT_EXECUTOR] === true; +} + +/** The destination of a fetch input, or null when it cannot be read as a URL. */ +export function egressTargetUrl(input: string | URL | Request): string | null { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return typeof input?.url === "string" ? input.url : null; +} + +/** Everything a physical send needs to decide the route for the request it is about to make. */ +export interface ProviderEgressBinding { + providerName: string; + provider: Pick; +} + +/** + * The request options expressing `binding`'s route for the destination actually being sent to. + * + * Resolved at the physical send rather than when the executor was built, for the reason #4992 + * already established for the connection policy: a queued request can be rebuilt against a + * different upstream host before it leaves, and a route decided against the original + * destination would then be applied to a different one. With a host-scoped `noProxy` that + * inverts the decision, and the credential leaves by a route the operator did not choose. + * + * Refuses rather than degrades when the selected executor owns its own transport. + */ +export function providerEgressSendInit( + binding: ProviderEgressBinding, + physicalFetch: unknown, + input: string | URL | Request, +): { proxy?: string | false } { + const url = egressTargetUrl(input); + if (url === null) return {}; + const egress = resolveProviderEgress({ providerName: binding.providerName, provider: binding.provider, url }); + if (providerEgressIsExplicit(egress) && !isEgressTransparentExecutor(physicalFetch)) { + // Name the field that actually made the route explicit. A bypass-list match with no + // `proxy` field at all would otherwise tell the operator to remove an override they + // never wrote. + const field = egress.kind === "direct" && egress.reason === "noProxy" ? "noProxy" : "proxy"; + throw new InvalidProviderEgressError( + field, + "the selected transport owns its own routing, so this route cannot be applied", + `providers.${binding.providerName}.${field} cannot be applied to the selected provider transport; ` + + "remove the provider egress override or the custom executor", + ); + } + return providerEgressFetchInit(egress); +} + +/** + * A destination used only to exercise the resolver at configuration time. + * + * Validation has no request URL, but `noProxy` is only meaningful against one. Resolving a + * reserved name checks the shape of both fields without asserting anything about which route a + * real request would take. + */ +const EGRESS_VALIDATION_URL = "https://validation.invalid/"; + +/** + * The configuration error for a provider's egress fields, or null when they are usable. + * + * Delegates to `resolveProviderEgress` so configuration and request time cannot drift apart: + * a value accepted by `ocx config set` or the dashboard is one the transport will accept, and + * one rejected here is rejected there for the identical reason. Restating the rules would give + * this repository two definitions of a valid proxy value and no check that they agree. + */ +export function providerEgressConfigError( + provider: Pick, +): string | null { + try { + resolveProviderEgress({ providerName: "", provider, url: EGRESS_VALIDATION_URL }); + return null; + } catch (error) { + if (error instanceof InvalidProviderEgressError) return `${error.field} is invalid: ${error.reason}`; + throw error; + } +} diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index d87893cfa4f..995e9b4c8fb 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -8,11 +8,12 @@ import { } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, schemeMatchedProxyFor } from "./proxy-env"; +import { InvalidProviderEgressError, resolveProviderEgress } from "./provider-egress"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; type ProviderPostInit = ProviderGetInit & { body: string }; -type ProviderOutboundConfig = Pick & { +type ProviderOutboundConfig = Pick & { fetch?: typeof globalThis.fetch; }; export interface ProviderOutboundDependencies { @@ -165,6 +166,17 @@ async function providerOutboundRequest( // throw inside discovery and fail the provider for a reason nothing in its configuration // explains; the built-in transport is what a configured value means. if (typeof provider.fetch === "function") { + // A caller-owned executor decides its own transport, so a provider egress route cannot be + // applied to it. Refusing is the only honest answer: running the executor anyway would send + // the request by whatever route that executor picked while the configuration says otherwise. + if (resolveProviderEgress({ providerName: name, provider, url }).kind !== "inherit") { + throw new InvalidProviderEgressError( + "proxy", + "a caller-supplied fetch executor owns its own routing, so this route cannot be applied", + `providers.${name}.proxy cannot be applied to a caller-supplied fetch executor; ` + + "remove the provider egress override or the custom executor", + ); + } // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config // checks and redirect blocking, but does not provide the resolved-address guarantees of // the built-in transport. Main-request migration must define that executor contract first. @@ -186,23 +198,39 @@ async function providerOutboundRequest( return provider.fetch(url, { ...init, method, redirect: "manual" }); } const parsed = postUrl ?? new URL(url); + // The provider's own route, decided against this request URL. `inherit` leaves every value + // below exactly as the global decision computed it. + const egress = resolveProviderEgress({ providerName: name, provider, url: parsed }); + const providerProxy = egress.kind === "proxy" ? egress.proxyUrl : null; // Snapshot the proxy fetch would actually use once, before the DNS await, so admission // and transport below reason about the same value. `null` here means "no proxy fetch // would actually use", even if some other proxy variable is set. - const effectiveProxy = effectiveProxyFor(parsed); + const globalProxy = effectiveProxyFor(parsed); // The request leaves the DNS-pinned transport only when a proxy will actually carry it: // a proxy variable fetch would use for this URL that NO_PROXY does not exempt. // A scheme-mismatched or unusable variable, a NO_PROXY match, or an ALL_PROXY // this target's scheme cannot use must not downgrade pinning or admit // proxy-only DNS answers. - const proxyApplies = effectiveProxy !== null && !noProxyMatches(parsed); + // + // A provider route replaces that decision outright rather than combining with it. An + // explicit provider proxy applies even where global NO_PROXY exempts the host, because the + // operator named this proxy for this provider; `providers..noProxy` is the exemption + // that belongs to that choice, and `resolveProviderEgress` has already applied it. A + // provider pinned to `direct` keeps the DNS-pinned transport, which reaches the peer + // through no proxy at all — the one route on this path that needs nothing from Bun. + const proxyApplies = egress.kind === "inherit" + ? globalProxy !== null && !noProxyMatches(parsed) + : providerProxy !== null; const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false); // The IPv6 fake-IP gate keeps its stricter documented condition — a // scheme-matched variable or a SOCKS5 ALL_PROXY, never a non-SOCKS // ALL_PROXY — even when proxyApplies admits one for the transport // decision, because admission binds the fetch to this value explicitly. - const bindingProxy = schemeMatchedProxyFor(parsed); - const allowMihomoIpv6FakeIp = (bindingProxy !== null && !noProxyMatches(parsed)) + // An explicit provider proxy is exactly such a binding: the fetch below is pinned to it. + const bindingProxy = egress.kind === "inherit" + ? schemeMatchedProxyFor(parsed) + : providerProxy; + const allowMihomoIpv6FakeIp = (bindingProxy !== null && (providerProxy !== null || !noProxyMatches(parsed))) || transparentFakeIpException(url, parsed, isCanonicalUrl, name); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; @@ -242,7 +270,12 @@ async function providerOutboundRequest( if (!proxyApplies) throw error; warnProxyBoundaryOnce(); warnProxyDnsDegradationOnce(); - return configuredOutboundFetch(url, { ...init, method, redirect: "manual" }); + // An explicit provider proxy stays pinned through the degradation too; re-inferring the + // route from the environment here would quietly move the request to a different exit. + return configuredOutboundFetch(url, { + ...init, method, redirect: "manual", + ...(providerProxy ? { proxy: providerProxy } : {}), + }); } // A canonical TUN exception with no scheme-matched proxy must retain the // validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present. @@ -250,7 +283,9 @@ async function providerOutboundRequest( warnProxyBoundaryOnce(); // When the Mihomo exception could have admitted an answer, pin the transport to the // proxy the admission assumed instead of letting fetch re-infer it from the environment. - const proxy = (allowMihomoIpv6FakeIp && bindingProxy) ? bindingProxy : undefined; + // An explicit provider proxy is always pinned, for the same reason and unconditionally: + // the operator named the exit for this provider, so the environment must not re-decide it. + const proxy = providerProxy ?? ((allowMihomoIpv6FakeIp && bindingProxy) ? bindingProxy : undefined); return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); } if (proxyApplies && resolved.privateNetwork) { diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index 3c3e80052dd..9469ccde982 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -174,16 +174,34 @@ export function socks5ProxyFromEnv(env: ProxyEnvMap = process.env): string | und return candidates.find(value => typeof value === "string" && isSocks5ProxyUrl(value)); } +/** + * A request-scoped proxy decision as the outbound transports express it. + * + * `false` is Bun's documented "connect directly": it overrides HTTP_PROXY, HTTPS_PROXY and + * ALL_PROXY, and it overrides NO_PROXY too. Bun treats `undefined`, `null` and `""` alike as + * "no option given" and falls back to the environment, so none of those can express direct + * egress. Declared locally because the value travels through `RequestInit`, which does not + * carry it in the ambient DOM types. + */ +export type ProxyCapableRequestInit = RequestInit & { proxy?: string | false }; + export function configuredOutboundFetch( input: RequestInfo | URL, init?: RequestInit, fallback?: typeof globalThis.fetch, ): Promise { const base = fallback ?? (globalThis.fetch === installedFetch ? nativeFetch : globalThis.fetch); - const explicitProxy = (init as (RequestInit & { proxy?: string }) | undefined)?.proxy; - const proxy = typeof explicitProxy === "string" - ? (isSocks5ProxyUrl(explicitProxy) ? explicitProxy : undefined) - : socks5ProxyFromEnv(); + const explicitProxy = (init as ProxyCapableRequestInit | undefined)?.proxy; + // An explicit `false` is a decision, so it also has to win over the installed SOCKS wrapper. + // Reading it as "no string was supplied" would fall through to ALL_PROXY and send a request + // the caller pinned to direct egress through the global SOCKS proxy instead — the silent + // substitution the caller asked this option to prevent. Bun applies the same `false` to its + // own HTTP(S) proxy environment once the request reaches the base fetch below. + const proxy = explicitProxy === false + ? undefined + : typeof explicitProxy === "string" + ? (isSocks5ProxyUrl(explicitProxy) ? explicitProxy : undefined) + : socks5ProxyFromEnv(); let url: URL; try { url = new URL(input instanceof Request ? input.url : String(input)); diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index 08544bc3c2c..6c44162ea43 100644 --- a/src/providers/quota/vendor-probes-key.ts +++ b/src/providers/quota/vendor-probes-key.ts @@ -1,6 +1,8 @@ import { resolveProviderApiKey } from "../key-store"; import { getProviderRegistryEntry, registryEntryForProviderDestination } from "../registry"; import { isCanonicalOllamaCloudUrl } from "../../adapters/ollama-native-url"; +import { providerEgressFetchInit, resolveProviderEgress } from "../../lib/provider-egress"; +import { configuredOutboundFetch } from "../../lib/proxy-env"; import { QUOTA_JSON_READ_FAILURE, asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS, toFiniteNumber } from "../quota-wire"; import { AUTHORITATIVE_EMPTY_QUOTA, @@ -15,6 +17,12 @@ import { getTokenForAccountQuotaProbe } from "./account-cache"; import type { AccountQuotaMode, ProviderQuota, ProviderQuotaCreditsUsd } from "../quota-types"; import type { OcxProviderConfig } from "../../types"; +// A quota probe must use the provider's inference route so it neither reports a false healthy path nor leaks a key through another exit. +async function quotaFetch(providerName: string, config: OcxProviderConfig, url: string, init: RequestInit): Promise { + const egress = resolveProviderEgress({ providerName, provider: config, url }); + return configuredOutboundFetch(url, { ...init, ...providerEgressFetchInit(egress) }); +} + const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`; const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai"; @@ -144,10 +152,10 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro if (!apiKey) return null; const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const; const [subscriptionResponse, tokenResponse] = await Promise.all([ - fetch(`${A6API_BASE_URL}/dashboard/billing/subscription`, { + quotaFetch(provider, config, `${A6API_BASE_URL}/dashboard/billing/subscription`, { headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }), - fetch(`${A6API_BASE_URL}/api/usage/token/`, { + quotaFetch(provider, config, `${A6API_BASE_URL}/api/usage/token/`, { headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }), ]); @@ -241,7 +249,7 @@ async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig) if (!isCanonicalOpenCodeGoBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(OPENCODE_GO_USAGE_URL, { + const response = await quotaFetch(provider, config, OPENCODE_GO_USAGE_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -287,7 +295,7 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(`${OPENROUTER_BASE_URL}/key`, { + const response = await quotaFetch(provider, config, `${OPENROUTER_BASE_URL}/key`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -337,7 +345,7 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, { + const response = await quotaFetch(provider, config, `${DEEPSEEK_BASE_URL}/user/balance`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -382,7 +390,7 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro if (!isCanonicalClineBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, { + const response = await quotaFetch(provider, config, `${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -480,7 +488,7 @@ async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(OLLAMA_CLOUD_USAGE_URL, { + const response = await quotaFetch(provider, config, OLLAMA_CLOUD_USAGE_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -604,7 +612,7 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; const authorization = monitorHost === ZAI_CN_BASE_URL ? apiKey : `Bearer ${apiKey}`; - const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, { + const response = await quotaFetch(provider, config, `${monitorHost}/api/monitor/usage/quota/limit`, { headers: { Accept: "application/json", Authorization: authorization }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -652,7 +660,7 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P if (!apiKey) return null; const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com"); const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL; - const response = await fetch(remainsUrl, { + const response = await quotaFetch(provider, config, remainsUrl, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -695,7 +703,7 @@ async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL; - const response = await fetch(`${host}/users/me/balance`, { + const response = await quotaFetch(provider, config, `${host}/users/me/balance`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -738,7 +746,7 @@ async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Pr if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, { + const response = await quotaFetch(provider, config, `${VENICE_BASE_URL}/billing/balance`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -781,7 +789,7 @@ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, { + const response = await quotaFetch(provider, config, `${SYNTHETIC_BASE_URL}/quotas`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -831,7 +839,7 @@ async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, { + const response = await quotaFetch(provider, config, `${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -873,7 +881,7 @@ async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig) if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, { + const response = await quotaFetch(provider, config, `${NEURALWATT_BASE_URL}/quota`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -1050,7 +1058,7 @@ export async function fetchKimiQuota(provider: string, config: OcxProviderConfig // Never release credentials to a user-edited or lookalike provider host. if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null; if (!accessToken) return null; - const response = await fetch(KIMI_CODE_USAGE_URL, { + const response = await quotaFetch(provider, config, KIMI_CODE_USAGE_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -1077,9 +1085,14 @@ function parseCommandCodeWindow(value: unknown): { percent: number; resetAt?: nu } /** Soft-fail GET returning a parsed record, or null when unavailable. */ -async function fetchCommandCodeJson(url: string, bearer: string): Promise | null> { +async function fetchCommandCodeJson( + provider: string, + config: OcxProviderConfig, + url: string, + bearer: string, +): Promise | null> { try { - const response = await fetch(url, { + const response = await quotaFetch(provider, config, url, { headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -1097,12 +1110,14 @@ async function fetchCommandCodeJson(url: string, bearer: string): Promise | null, orgQuery: string, ): Promise { if (!credits) return undefined; - const subscriptionBody = await fetchCommandCodeJson(`${COMMAND_CODE_SUBSCRIPTIONS_URL}${orgQuery}`, bearer); + const subscriptionBody = await fetchCommandCodeJson(provider, config, `${COMMAND_CODE_SUBSCRIPTIONS_URL}${orgQuery}`, bearer); const subscription = asRecord(subscriptionBody?.data) ?? subscriptionBody; const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart.trim() : ""; // Unscoped /usage/summary is lifetime spend; mixing it with current-cycle @@ -1110,7 +1125,7 @@ async function fetchCommandCodeSpend( if (!periodStart) return undefined; const sinceQuery = `${orgQuery ? "&" : "?"}since=${encodeURIComponent(periodStart)}`; const expiresAt = normalizeResetAt(subscription?.currentPeriodEnd); - const summaryBody = await fetchCommandCodeJson(`${COMMAND_CODE_USAGE_URL}${orgQuery}${sinceQuery}`, bearer); + const summaryBody = await fetchCommandCodeJson(provider, config, `${COMMAND_CODE_USAGE_URL}${orgQuery}${sinceQuery}`, bearer); const summary = asRecord(summaryBody?.data) ?? summaryBody; const used = toFiniteNumber(summary?.totalCost) ?? toFiniteNumber(summary?.totalMonthlyCredits); if (used === undefined || used < 0) return undefined; @@ -1161,12 +1176,12 @@ export async function fetchCommandCodeQuota(provider: string, config: OcxProvide // Never release credentials to a user-edited or lookalike provider host. if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null; if (!bearer) return null; - const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer); + const whoamiBody = await fetchCommandCodeJson(provider, config, COMMAND_CODE_WHOAMI_URL, bearer); const whoami = asRecord(whoamiBody?.data) ?? whoamiBody; const org = asRecord(whoami?.org); const orgId = typeof org?.id === "string" && org.id.trim() ? org.id.trim() : null; const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : ""; - const response = await fetch(`${COMMAND_CODE_CREDITS_URL}${orgQuery}`, { + const response = await quotaFetch(provider, config, `${COMMAND_CODE_CREDITS_URL}${orgQuery}`, { headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -1183,7 +1198,7 @@ export async function fetchCommandCodeQuota(provider: string, config: OcxProvide if (!credits && !limits) return null; const fiveHour = parseCommandCodeWindow(limits?.fiveHour); const weekly = parseCommandCodeWindow(limits?.weekly); - const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery); + const creditsUsd = await fetchCommandCodeSpend(provider, config, bearer, credits, orgQuery); const quota: ProviderQuota = { ...(fiveHour ? { fiveHourPercent: fiveHour.percent, diff --git a/src/providers/xai-transport.ts b/src/providers/xai-transport.ts index e005abdf4e6..76582b07298 100644 --- a/src/providers/xai-transport.ts +++ b/src/providers/xai-transport.ts @@ -1,5 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import type { OcxProviderConfig } from "../types"; +import { isEgressTransparentExecutor, markEgressTransparentExecutor } from "../lib/provider-egress"; +import { configuredOutboundFetch } from "../lib/proxy-env"; import { resolveGithubCopilotTransport } from "./github-copilot-transport"; export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; @@ -156,9 +158,18 @@ export function resolveProviderTransport( // transient retries reuse one id so the upstream can dedupe them. A rotated key resolves a // fresh transport, which gets its own id. const requestId = configuredRequestId ?? randomUUID(); - const baseFetch = provider.fetch ?? globalThis.fetch; + // Without a configured executor the default routes through `configuredOutboundFetch` rather + // than the bare global fetch, so a per-provider SOCKS5 route reaches the SOCKS transport. + // Bare Bun fetch ignores a socks5 value, which would have sent the request unproxied while + // the configuration named a proxy. + const baseFetch = provider.fetch + ?? markEgressTransparentExecutor(((input, init) => configuredOutboundFetch(input, init)) as typeof globalThis.fetch); const attemptFetch = ((input, init) => baseFetch(input, withGeneratedRequestId(init, requestId, stableHeaders))) as typeof globalThis.fetch; + // This wrapper only adds a header and forwards the init, so it carries a request-scoped proxy + // option through to whatever it wraps — but only if what it wraps carries it too. A + // configured executor owns its own routing and is not assumed to. + if (isEgressTransparentExecutor(baseFetch)) markEgressTransparentExecutor(attemptFetch); return { ...provider, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index e5228d8272c..ad987bf4e5a 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -29,6 +29,7 @@ import { upstreamHttpVersionConfigError, } from "../config/provider-validation"; import { providerDestinationConfigError } from "../lib/destination-policy"; +import { providerEgressConfigError } from "../lib/provider-egress"; import { redactSecretString } from "../lib/redact"; import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy"; import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; @@ -780,6 +781,13 @@ export function providerManagementConfigError( if (upstreamHttpVersionError) { return `provider ${JSON.stringify(redactSecretString(name))} ${upstreamHttpVersionError}`; } + // Per-provider egress shares one definition with the transports and the config loader, so a + // value the dashboard accepts is one a request can actually leave by. The message never + // echoes the value: a proxy URL routinely embeds `user:password@`. + const egressError = providerEgressConfigError(typed); + if (egressError) { + return `provider ${JSON.stringify(redactSecretString(name))} ${egressError}`; + } const modelCostsError = providerModelCostsConfigError(raw.modelCosts); if (modelCostsError) { // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape @@ -947,6 +955,13 @@ const PROVIDER_CONFIG_FIELD_POLICY = { decodesNativeCompactionBlobs: "editor", allowEncryptedV2AgentTasks: "editor", allowPrivateNetwork: "editor", + // A proxy URL routinely embeds `user:password@`, so it never reaches the dashboard DTO and + // the editor may not write it. `ocx config set` and the config file remain the way to set + // it, which is the same boundary `apiKey` sits behind and for the same reason. + proxy: "redacted", + // A bypass list names destinations, carries no credential, and is only meaningful next to a + // route the operator can already see. + noProxy: "editor", upstreamHttpVersion: "editor", upstreamWebsocket: "editor", directGeminiWireRenames: "editor", diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 69361aa9f93..c77b6fda592 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -359,6 +359,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio applyUpstreamRecoveryInit({ ...init, method: request.method, headers, body: request.body, }, transportRecovery), + // Reselection can replace the provider transport and the wire shape, so the + // egress route is bound to the provider this send actually uses. Omitting it + // here would let a provider transport bypass its configured route entirely, + // because that transport wins over the executor that carries the binding. + { providerName: route.providerName, provider: activeProvider }, ); if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal); return dispatched; diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index b2e44969740..917d74f8ad0 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -12,9 +12,57 @@ import { waitForProviderRequestSlot } from "../../providers/request-pacing"; import { withUpstreamHttpVersion } from "../../lib/upstream-http-version"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; import { configuredOutboundFetch } from "../../lib/proxy-env"; +import { + describeProviderEgressForLog, + markEgressTransparentExecutor, + providerEgressSendInit, + providerEgressIsExplicit, + resolveProviderEgress, + type ProviderEgressBinding, +} from "../../lib/provider-egress"; +import { redactSecretString } from "../../lib/redact"; export { withUpstreamHttpVersion }; +const egressWebsocketDowngradeWarned = new Set(); +/** A provider name is configuration-controlled, so the notice set is bounded like any cache. */ +const EGRESS_DOWNGRADE_NOTICE_LIMIT = 64; +/** + * Marks an init whose provider egress route an outer physical-send boundary already decided. + * + * Own symbol keys survive object spread, so the mark travels through the rebuild a + * `dispatchOverride` performs, and an unknown symbol on a `RequestInit` is inert at the wire. + */ +const EGRESS_DECIDED = Symbol.for("opencodex.provider-egress.decided"); + +/** + * Announce once, per provider, that an explicit egress route moved this provider off the + * WebSocket fast lane. + * + * The WebSocket upstream selects its proxy from the process environment when it dials, so it + * cannot carry a per-provider route. Serving the turn over HTTP/SSE honours the operator's + * egress choice, which is the one that has to win — but a transport change the operator did + * not ask for is exactly the kind of substitution this batch refuses to make silently, so it + * is stated rather than merely done. + */ +function warnEgressWebsocketDowngradeOnce(providerName: string, egress: string): void { + if (egressWebsocketDowngradeWarned.has(providerName)) return; + if (egressWebsocketDowngradeWarned.size >= EGRESS_DOWNGRADE_NOTICE_LIMIT) return; + egressWebsocketDowngradeWarned.add(providerName); + console.warn( + // The name is caller-controlled and can be token-shaped, so it is redacted and JSON-escaped + // before it reaches a log, exactly as at the management error boundary. + `[opencodex] provider ${JSON.stringify(redactSecretString(providerName))} declares egress ${egress}; the WebSocket upstream ` + + "selects its proxy from the process environment and cannot carry a per-provider route, " + + "so these turns are served over HTTP/SSE.", + ); +} + +/** Test seam: the downgrade notice is once per provider per process, not once per request. */ +export function __resetEgressWebsocketDowngradeNotices(): void { + egressWebsocketDowngradeWarned.clear(); +} + export function disableResponsesRequestTimeout(req: Request, server: Pick, "timeout"> | undefined): boolean { if (!server) return false; try { @@ -97,17 +145,31 @@ export function sendWithConnectionPolicy( physicalFetch: typeof globalThis.fetch, input: Parameters[0], init?: RequestInit, + egress?: ProviderEgressBinding, ): Promise { const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); const fresh = wantsFreshConnection(input); if (fresh) { headers.set("Connection", "close"); } + // Decided here, against the destination this send is actually going to, and around whichever + // executor was just selected. A `dispatchOverride` that rebuilds a queued request can change + // both the upstream host and the provider transport after the wrapper was constructed, so a + // route resolved at construction could be applied to a different host than it was decided for. + // These calls nest: an override decides with its own binding and then hands the send to the + // executor `providerFetch` supplied, which is another one of these. The outermost caller holds + // the reselected provider and the rebuilt destination, so it decides and marks the init; the + // inner pass honours that mark rather than recomputing from a stale closure. + const alreadyDecided = (init as Record | undefined)?.[EGRESS_DECIDED] === true; + const decide = egress !== undefined && !alreadyDecided; + const egressInit = decide ? providerEgressSendInit(egress, physicalFetch, input) : {}; return physicalFetch(input, { ...init, headers, redirect: "manual", ...(fresh ? { keepalive: false } : {}), + ...egressInit, + ...(decide ? { [EGRESS_DECIDED]: true } : {}), }); } @@ -130,28 +192,63 @@ export function providerFetch( runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), options: ProviderFetchOptions = {}, ): ProviderFetch { - const configuredFetch = Object.assign( + const providerName = options.providerName ?? ""; + const customExecutor = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch; + // The route is applied at the physical send (see `sendWithConnectionPolicy`). This binding is + // only what that boundary needs to decide it. + const egressBinding: ProviderEgressBinding = { providerName, provider }; + // Resolved per request, not once per wrapper: `providers..noProxy` is evaluated against + // the destination, so two requests through the same executor can legitimately take different + // routes. A malformed value throws and rejects the request rather than degrading to the + // global proxy or to direct, either of which would read as success at the call site. + const egressFor = (input: Parameters[0]) => resolveProviderEgress({ + providerName, + provider, + url: typeof input === "string" ? input : input instanceof URL ? input : input.url, + }); + // The built-in executor forwards its init to a transport that honours the proxy option. + const configuredFetch = markEgressTransparentExecutor(Object.assign( (input: Parameters[0], init?: RequestInit) => configuredOutboundFetch(input, init), { preconnect: globalThis.fetch.preconnect?.bind(globalThis.fetch) }, - ) as typeof globalThis.fetch; - const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? configuredFetch; + ) as typeof globalThis.fetch); + const base = customExecutor ?? configuredFetch; const preconnect = (...args: Parameters): void => { base.preconnect?.(...args); }; // Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends. // Return the original 3xx so the response owner retains its retry/health/relay contract. - const dispatch = Object.assign( + // + // Marked transparent because it forwards its init to a transport that honours the proxy + // option. Leaving it unmarked would make an ordinary configured provider refuse its own route + // on every overridden path, after the attempt had already been recorded — an override selects + // `provider.fetch ?? execute`, and `execute` is this wrapper. It still carries the binding, so + // an override that simply calls it gets the route decided rather than dropped; an override + // that decided for itself has already marked the init and this pass defers to that decision. + const dispatch = markEgressTransparentExecutor(Object.assign( (input: Parameters[0], init?: RequestInit) => - sendWithConnectionPolicy(base, input, init), + sendWithConnectionPolicy(base, input, init, egressBinding), { preconnect }, - ) as typeof globalThis.fetch; + ) as typeof globalThis.fetch); const httpFetch = Object.assign( async (input: Parameters[0], init?: RequestInit) => { + // Refuse before any dispatch side effect where that is sound. `beforeDispatch` commits + // attempt accounting and consumes admission state, so a refusal firing after it would + // charge an attempt for a send that never happens, and a throwing hook would mask the + // egress error with an unrelated one. + // + // With no override, this input and `base` ARE the final destination and executor, so the + // full decision can be made now. With an override, only the configured value is checked: + // the override may rebuild against a different host and select a different transport, and + // refusing on this destination would reject a request whose real route is fine. + if (options.dispatchOverride) egressFor(input); + else providerEgressSendInit(egressBinding, base, input); // The hook inspects the outgoing headers and refuses the send by throwing; it is not a // mutator, and the copy it receives is deliberately not threaded onward. `Connection` // is decided inside `dispatch`, which runs after this, so the fresh-connection policy // wins regardless of what any caller or hook put in the header. options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); + // No proxy option is attached here: a `dispatchOverride` may rebuild this request against + // a different destination, so the route is decided at the physical send instead. const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }; return options.dispatchOverride ? options.dispatchOverride(input, dispatchInit, dispatch) @@ -165,6 +262,11 @@ export function providerFetch( const unpaced = async (input: Parameters[0], init?: RequestInit) => { const upstreamWebsocket = provider.upstreamWebsocket === true; if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { + const egress = egressFor(input); + if (providerEgressIsExplicit(egress)) { + warnEgressWebsocketDowngradeOnce(providerName, describeProviderEgressForLog(egress)); + return httpFetch(input, init); + } // The fallback has to be the same HTTP fetch the non-WS branch would have // used, protocol pin included: a WS turn that falls back is serving the // request over HTTP, and dropping the provider's `upstreamHttpVersion` @@ -188,11 +290,16 @@ export function providerFetch( await waitForPacing(init?.signal ?? undefined); return unpaced(input, init); }; - return Object.assign(wrapped, { + // The returned wrapper forwards its init down to `dispatch`, which applies the route at the + // physical send. Adapters that hand this executor back as `provider.fetch` (Cursor does) + // therefore still carry a per-provider route instead of being refused as opaque. + const paceAware = Object.assign(wrapped, { preconnect, waitForPacing, unpacedFetch: Object.assign(unpaced, { preconnect }), }); + markEgressTransparentExecutor(paceAware as unknown as typeof globalThis.fetch); + return paceAware; } diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index 11354c79497..46d70186289 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -409,7 +409,16 @@ export async function prepareResponsesTransport( // Either way the send crosses the physical boundary, so the connection policy is // applied around whichever implementation was just selected (#4992). commitKeyAttemptSend(); - const response = await sendWithConnectionPolicy(fetchImpl, destination, { ...dispatchInit, redirect: "manual" }); + // The binding travels with the send, so a rebuilt request resolves its provider route + // against the destination it is actually going to rather than the one this dispatch + // started with. Account reselection can move the upstream host, which would otherwise + // apply a host-scoped decision to a different host. + const response = await sendWithConnectionPolicy( + fetchImpl, + destination, + { ...dispatchInit, redirect: "manual" }, + { providerName: route.providerName, provider: route.provider }, + ); if (!response.ok) await recordKeyAttemptFailure(logCtx, response, dispatchInit.signal ?? options.abortSignal); // Observe each physical response before retries replace it. The binding belongs to // this dispatch, so a manual switch cannot file A's headers against B. Header diff --git a/src/types/provider.ts b/src/types/provider.ts index bbc3d6b5c35..5e0bd79b583 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -388,6 +388,35 @@ export interface OcxProviderConfig { * link-local, or unique-local upstreams. Metadata endpoints remain blocked. */ allowPrivateNetwork?: boolean; + /** + * Outbound egress for THIS provider, overriding the process-wide `proxy` decision. + * + * The global `proxy` is one value for every upstream, so it cannot express the split + * operators actually need: reach one gateway through a regional proxy while another stays + * direct on the local network (#2894). Accepted values: + * + * - absent — inherit the global proxy decision. Unchanged behaviour. + * - `"direct"` or `null` — never use the global proxy for this provider. + * - `"http://…"` / `"https://…"` — this provider's own HTTP(S) proxy. + * - `"socks5://…"` / `"socks5h://…"` — this provider's own SOCKS5 proxy. + * + * An empty string is rejected rather than read as DIRECT: a cleared dashboard field must not + * silently switch a provider from inheriting the global proxy to refusing it. A malformed + * value is rejected at configuration time and again at request time; it never degrades to + * either neighbour, because both degradations look like success at the call site. + * + * Not every transport can carry this. `structure/transports/inventory.md` records which + * request paths honour it and which still follow the process-wide value only. + */ + proxy?: string | null; + /** + * Destinations this provider reaches without a proxy, in `NO_PROXY` syntax. + * + * Applied to whichever route `proxy` resolved to, so it carves an exemption out of this + * provider's own proxy AND out of an inherited global one. That second case is how a + * provider exempts a single host without owning a proxy of its own. + */ + noProxy?: string | string[]; /** * Pin the HTTP version used for upstream provider requests. Bun's fetch negotiates * HTTP/2 via TLS ALPN by default; some Cloudflare-fronted SSE endpoints hang on diff --git a/structure/config.md b/structure/config.md index 47e0fb54033..18e805c284b 100644 --- a/structure/config.md +++ b/structure/config.md @@ -90,6 +90,7 @@ matters for maintainers is which groups exist and who resolves them: | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | | Spend | `spend.root`, `spend.identity`, `spend.pool`, `spend.retentionDays` | Durable token ceilings for the spend-reservation ledger. Absent is the default and means observe-only accounting: spend is still journaled and nothing is refused, so observe-only and enforced servers take the same state-directory writer lease. One live process may write one directory; explicit sibling instances need separate `OPENCODEX_HOME` directories. There is no default figure for any scope — the ledger is on by default, so a shipped ceiling would refuse real traffic on upgrade against a number nobody chose. Strictly validated and positive-integer only, because 0 would read as a budget and refuse everything; a malformed section degrades to no ceiling, which is why the write path rejects it and load diagnostics report it. Resolution and application live in `src/lib/spend-reservation-ledger.ts`; see [`transports/responses.md`](transports/responses.md). | | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | +| Provider egress | `providers..proxy`, `providers..noProxy` | An absent `proxy` inherits global egress; `"direct"` or `null` forces direct egress; HTTP(S) and SOCKS5(H) URLs select a provider-owned proxy. `noProxy` uses NO_PROXY syntax and sends a matching destination direct across either a provider-owned or inherited global proxy. `src/lib/provider-egress.ts` owns parsing and request-local resolution. | | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | | Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`gui-and-management-api.md`](gui-and-management-api.md). | diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ddfe0a022c2..ed5b26d601e 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -119,6 +119,32 @@ rejected body returns no credentials, an oversized, malformed, or aborted key re login before credential persistence or dashboard convergence, leaving only the fixed size-limit or invalid-JSON message described above. +## Per-provider egress coverage + +`src/lib/provider-egress.ts` resolves a provider route for one destination. The route is carried only +by transports that can preserve that request-local decision: + +| Request path | Per-provider route | Current contract | +| --- | --- | --- | +| Main routed inference through `providerFetch` in `src/server/responses/fetch-helpers.ts` | Honoured | Applied at the physical send in `sendWithConnectionPolicy`, so a request rebuilt against a different destination or a reselected provider transport resolves its route against the destination actually used. The built-in executor passes direct, HTTP(S)-proxy, and SOCKS5(H)-proxy choices through `configuredOutboundFetch` in `src/lib/proxy-env.ts`; an inherited route leaves the global decision unchanged. Native Chat in `src/server/chat-native.ts` and the Responses transport in `src/server/responses/request-transport.ts` bind their own sends. | +| Every caller of `providerOutboundGet` or `providerOutboundPost` in `src/lib/provider-outbound.ts` | Honoured | This includes provider discovery and model-catalog gathering in `src/codex/catalog/provider-models.ts`, management provider tests in `src/server/management/provider-routes.ts`, and the Ollama show probe in `src/providers/ollama-show.ts`. | +| API-key quota probes in `src/providers/quota/vendor-probes-key.ts` | Honoured | Each probe receives its provider config and sends through `configuredOutboundFetch` with the resolved route, so a quota reading and the inference it describes leave by the same exit. | +| OAuth token exchange and refresh under `src/oauth/` | Not honoured | These reach fixed vendor endpoints from modules that hold no provider config, so no provider route is in scope at the call site. A provider pinned to its own proxy or to direct still refreshes credentials by the process-wide route. | +| OAuth-backed quota probes in `src/providers/quota/vendor-probes-oauth.ts` | Not honoured | `fetchXaiQuota`, `fetchAnthropicQuota`, `fetchCursorQuota` and their neighbours receive a provider name and a token rather than a provider config. | +| API-key validation probes in `src/oauth/key-providers.ts` | Not honoured | `validateApiKey` receives a `KeyLoginProvider` derived preset, which carries no egress fields, and its caller builds the real provider record afterwards. | +| Responses WebSocket upstream in `src/server/responses/ws-upstream.ts` | Not directly | The WebSocket dial selects its proxy from the process environment. An explicit provider route therefore serves that provider's turns over HTTP/SSE instead and emits one warning per provider per process. | +| Caller-supplied `provider.fetch` executor | Not honoured | The caller owns that executor's transport. An explicit provider route is refused instead of being ignored. | +| Cursor's default HTTP/2 transport in `src/adapters/cursor/live-transport.ts` | Not honoured | The native HTTP/2 dial does not consume the provider route. | +| Coding-agent subprocess providers in `src/adapters/coding-agent/turn.ts` | Not honoured | Their scoped child environment omits proxy variables, so a provider route is not projected into the subprocess. | +| Compatibility Lab pinned sender in `src/lib/lab-live-pinned-sender.ts` | Not honoured | The sender uses the approved pinned address and does not resolve a provider route. | + +The following authenticated data-plane endpoints do not resolve a provider route because they do +not route a model through the router: `/v1/images/generations`, `/v1/images/edits`, +`/v1/audio/transcriptions` and `/v1/audio/transcriptions/stream`, `/v1/live`, +`/v1/realtime/calls`, the standalone realtime WebSocket routes, and the non-account-qualified +branch of `/v1/alpha/search`. Their dispatch remains with the endpoint owners in +`src/server/index/serve-options.ts`. + ## Provider diagnostic outbound safety Google tool-schema loss diagnostics follow the same outbound boundary. The compiler retains only diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 01d9da0b04d..0dc6bec9f2c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -4,6 +4,10 @@ "provider-send-path-import.test.ts": "server", "socks5-fetch.test.ts": "lib", "socks5-upload-lifecycle.test.ts": "lib", + "provider-egress.test.ts": "lib", + "provider-egress-outbound.test.ts": "providers", + "provider-egress-fetch.test.ts": "responses", + "provider-egress-management-validation.test.ts": "server", "start-args.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", diff --git a/tests/lib/provider-egress.test.ts b/tests/lib/provider-egress.test.ts new file mode 100644 index 00000000000..000b7b1a6a2 --- /dev/null +++ b/tests/lib/provider-egress.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + InvalidProviderEgressError, + PROVIDER_EGRESS_DIRECT, + describeProviderEgressForLog, + providerEgressConfigError, + providerEgressFetchInit, + providerEgressIsExplicit, + resolveProviderEgress, + sanitizeProxyUrlForLog, +} from "../../src/lib/provider-egress"; +import { PROXY_ENV_KEYS, configuredOutboundFetch } from "../../src/lib/proxy-env"; + +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + +afterEach(() => { + for (const key of proxyKeys) { + const previous = originalProxyEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } +}); + +const TARGET = "https://api.provider.example/v1/responses"; + +function resolve(provider: { proxy?: string | null; noProxy?: string | string[] }, url = TARGET) { + return resolveProviderEgress({ providerName: "vendor", provider, url }); +} + +describe("provider egress resolution", () => { + test("an absent field inherits the global decision rather than choosing a route", () => { + const egress = resolve({}); + expect(egress).toEqual({ kind: "inherit" }); + expect(providerEgressIsExplicit(egress)).toBe(false); + // Inheriting must contribute no request option at all: a provider that says nothing has to + // leave the global proxy decision byte-identical to what it was before this field existed. + expect(providerEgressFetchInit(egress)).toEqual({}); + }); + + test("the direct keyword and null both refuse the global proxy", () => { + for (const value of [PROVIDER_EGRESS_DIRECT, PROVIDER_EGRESS_DIRECT.toUpperCase(), null] as const) { + const egress = resolve({ proxy: value }); + expect(egress).toEqual({ kind: "direct", reason: "configured" }); + expect(providerEgressIsExplicit(egress)).toBe(true); + } + }); + + test("an http(s) URL routes this provider through its own proxy", () => { + expect(resolve({ proxy: "http://egress.example:3128" })).toEqual({ + kind: "proxy", proxyUrl: "http://egress.example:3128/", transport: "http", + }); + // An https proxy is still the HTTP(S) CONNECT transport; `transport` names the transport + // family the request is handed to, not the proxy's own scheme. + expect(resolve({ proxy: "https://egress.example:3129" })).toEqual({ + kind: "proxy", proxyUrl: "https://egress.example:3129/", transport: "http", + }); + }); + + test("a socks5 URL is carried verbatim so the SOCKS transport can parse it", () => { + // Not normalized through URL.toString(): the SOCKS transport validates the original value, + // including credentials and the socks5h variant, and a reserialized URL is not guaranteed + // to round-trip the userinfo it was given. + for (const value of ["socks5://127.0.0.1:1080", "socks5h://127.0.0.1:1080"]) { + expect(resolve({ proxy: value })).toEqual({ kind: "proxy", proxyUrl: value, transport: "socks5" }); + } + }); + + test("a provider noProxy match forces direct egress out of the provider's own proxy", () => { + const provider = { proxy: "http://egress.example:3128", noProxy: "internal.example" }; + expect(resolve(provider, "https://internal.example/v1/models")) + .toEqual({ kind: "direct", reason: "noProxy" }); + // A destination the list does not name still takes the provider's proxy. + expect(resolve(provider).kind).toBe("proxy"); + }); + + test("a provider noProxy match also carves an exemption out of an inherited global proxy", () => { + // This is the case a provider-level proxy cannot express: the provider owns no route of its + // own and only needs one destination kept off the global one. + expect(resolve({ noProxy: ["internal.example", "10.0.0.1"] }, "https://internal.example/v1/models")) + .toEqual({ kind: "direct", reason: "noProxy" }); + expect(resolve({ noProxy: ["internal.example"] })).toEqual({ kind: "inherit" }); + }); + + test("an empty value is refused instead of being read as either neighbour", () => { + // The failure this prevents: a cleared dashboard field silently switching a provider from + // "inherit the global proxy" to "never use it", or the reverse. Both read as success. + for (const value of ["", " "]) { + expect(() => resolve({ proxy: value })).toThrow(InvalidProviderEgressError); + } + const message = providerEgressConfigError({ proxy: "" }); + expect(message).toContain(PROVIDER_EGRESS_DIRECT); + }); + + test("a malformed or unsupported value throws rather than degrading to a working route", () => { + for (const value of ["not a url", "ftp://egress.example", "://", "socks4://127.0.0.1:1080"]) { + expect(() => resolve({ proxy: value })).toThrow(InvalidProviderEgressError); + expect(providerEgressConfigError({ proxy: value })).not.toBeNull(); + } + expect(() => resolve({ proxy: 42 as unknown as string })).toThrow(InvalidProviderEgressError); + expect(providerEgressConfigError({ noProxy: [7 as unknown as string] })).not.toBeNull(); + }); + + test("configuration and request time share one definition of a valid value", () => { + // Two definitions would drift, and nothing would compare them. A value the loader accepts + // has to be one a request can actually leave by. + for (const value of [PROVIDER_EGRESS_DIRECT, "http://egress.example:3128", "socks5://127.0.0.1:1080"]) { + expect(providerEgressConfigError({ proxy: value })).toBeNull(); + expect(() => resolve({ proxy: value })).not.toThrow(); + } + expect(providerEgressConfigError({})).toBeNull(); + }); +}); + +describe("provider egress never reproduces a proxy credential", () => { + test("log output keeps scheme, host and port and drops everything else", () => { + // A `.test` host, because a credentialed proxy URL reads as `password@host` to the privacy + // scanner and that domain is on its allowed list for fixtures. + const secret = "http://operator:hunter2@egress.test:3128/path?token=abc"; + const label = sanitizeProxyUrlForLog(secret); + expect(label).toBe("http://egress.test:3128"); + for (const fragment of ["operator", "hunter2", "token", "abc"]) { + expect(label).not.toContain(fragment); + } + }); + + test("the described route carries no digest of the credential either", () => { + // A short hash over a known host is a guessable stand-in for the secret and a durable + // correlation key for the account behind it, so the description derives nothing from it. + const described = describeProviderEgressForLog(resolve({ proxy: "http://operator:hunter2@egress.test:3128" })); + expect(described).toBe("http(http://egress.test:3128)"); + expect(described).not.toContain("hunter2"); + expect(describeProviderEgressForLog({ kind: "inherit" })).toBe("inherit"); + expect(describeProviderEgressForLog({ kind: "direct", reason: "noProxy" })).toBe("direct(noProxy)"); + }); + + test("an unparseable value is labelled without being echoed", () => { + const label = sanitizeProxyUrlForLog("operator hunter2 not a url"); + expect(label).toBe(""); + expect(label).not.toContain("hunter2"); + }); +}); + +describe("direct egress overrides the installed SOCKS transport", () => { + test("a request pinned to direct is not sent through the global SOCKS proxy", async () => { + // The regression: `proxy: false` is not a string, so reading it as "no explicit proxy was + // supplied" fell through to ALL_PROXY and sent a request the caller pinned to direct egress + // through the global SOCKS proxy instead. The request would have succeeded, by the wrong exit. + for (const key of proxyKeys) delete process.env[key]; + process.env.ALL_PROXY = "socks5://127.0.0.1:1"; + const base = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + return new Response(JSON.stringify({ proxy: (init as { proxy?: unknown }).proxy ?? null }), { status: 200 }); + }); + const response = await configuredOutboundFetch( + TARGET, + providerEgressFetchInit(resolve({ proxy: PROVIDER_EGRESS_DIRECT })) as RequestInit, + base as unknown as typeof globalThis.fetch, + ); + expect(base).toHaveBeenCalledTimes(1); + // Bun reads `false` as "connect directly", overriding HTTP_PROXY, HTTPS_PROXY, ALL_PROXY + // and NO_PROXY alike. `undefined`, `null` and `""` all mean "no option" and fall back to + // the environment, so none of them can express this. + expect(await response.json()).toEqual({ proxy: false }); + }); + + test("an inheriting provider still reaches the global SOCKS transport unchanged", async () => { + for (const key of proxyKeys) delete process.env[key]; + // Port 0 is rejected by the SOCKS transport's own validation before any socket is opened, + // so the outcome does not depend on what happens to be listening on the test host. What is + // asserted is which transport took the request, not that it succeeded. + process.env.ALL_PROXY = "socks5://127.0.0.1:0"; + const base = mock(async () => new Response(null, { status: 200 })); + // With no explicit option the SOCKS wrapper owns the request, so the base fetch below is + // never reached. Asserting that keeps this change from quietly disabling global SOCKS. + const outcome = await configuredOutboundFetch( + TARGET, + providerEgressFetchInit(resolve({})) as RequestInit, + base as unknown as typeof globalThis.fetch, + ).then(() => "base-fetch-used", () => "socks-transport-owned-the-request"); + expect(outcome).toBe("socks-transport-owned-the-request"); + expect(base).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/providers/provider-egress-outbound.test.ts b/tests/providers/provider-egress-outbound.test.ts new file mode 100644 index 00000000000..bdf233dc1df --- /dev/null +++ b/tests/providers/provider-egress-outbound.test.ts @@ -0,0 +1,247 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { DestinationDnsResolutionError } from "../../src/lib/destination-policy"; +import type { ProviderOutboundDependencies } from "../../src/lib/provider-outbound"; +import { InvalidProviderEgressError, PROVIDER_EGRESS_DIRECT } from "../../src/lib/provider-egress"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; + +/** + * Provider discovery and quota probes share one transport chokepoint, `providerOutboundRequest`. + * Every caller of `providerOutboundGet`/`providerOutboundPost` — provider discovery, the + * model-catalog gather, the management provider test and the Ollama show probe — reaches the + * wire through the decision these cases pin. + * + * What makes these regressions rather than smoke tests: every one of them would pass if the + * provider route were ignored entirely, as long as the assertion were only "the request + * succeeded". Each case therefore asserts WHICH transport carried the request and WHICH proxy + * value it was pinned to. + */ +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + +afterEach(() => { + for (const key of proxyKeys) { + const previous = originalProxyEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } +}); + +const MODELS_URL = "https://provider.example/v1/models"; +const GLOBAL_PROXY = "http://global-egress.example:3128"; +const PROVIDER_PROXY = "http://provider-egress.example:8080"; + +function pinnedDependencies(options?: { dnsFails?: boolean }): { + dependencies: ProviderOutboundDependencies; + captured: { address?: string }; +} { + const captured: { address?: string } = {}; + return { + captured, + dependencies: { + resolveAddresses: mock(async () => { + if (options?.dnsFails) throw new DestinationDnsResolutionError("provider.example did not resolve"); + return { hostname: "provider.example", addresses: [{ address: "93.184.216.34", family: 4 }], privateNetwork: false }; + }), + pinnedGet: mock(async (_url, pinned) => { + captured.address = pinned.address; + return new Response('{"data":[]}', { status: 200, headers: { "content-type": "application/json" } }); + }), + pinnedPost: mock(async (_url, pinned) => { + captured.address = pinned.address; + return new Response('{"data":[]}', { status: 200, headers: { "content-type": "application/json" } }); + }), + }, + }; +} + +/** Replace the global fetch and record the request-scoped proxy each call was pinned to. */ +function captureProxiedFetch(): { calls: Array; restore: () => void } { + const calls: Array = []; + const original = globalThis.fetch; + const stub = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + calls.push((init as { proxy?: unknown } | undefined)?.proxy); + return new Response('{"data":[]}', { status: 200, headers: { "content-type": "application/json" } }); + }); + globalThis.fetch = stub as unknown as typeof globalThis.fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +describe("per-provider egress on the discovery and quota transport", () => { + test("a provider pinned to direct keeps the DNS-pinned transport while a global proxy is set", async () => { + // The DNS-pinned transport connects to an address this process resolved, through node:http, + // which never reads the proxy environment. That is what makes direct egress expressible + // here without asking anything of the runtime's own proxy handling. + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + process.env.https_proxy = GLOBAL_PROXY; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = pinnedDependencies(); + const proxied = captureProxiedFetch(); + try { + const response = await providerOutboundGet( + "vendor", + { baseUrl: "https://provider.example", proxy: PROVIDER_EGRESS_DIRECT }, + MODELS_URL, {}, dependencies, + ); + expect(response.status).toBe(200); + expect(captured.address).toBe("93.184.216.34"); + expect(proxied.calls).toEqual([]); + } finally { + proxied.restore(); + } + }); + + test("a provider proxy is pinned onto the request instead of being re-inferred from the environment", async () => { + // A global proxy is set to a DIFFERENT value on purpose: passing the request to fetch + // without pinning would let the environment decide, and the request would still succeed + // through the wrong exit. + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + process.env.https_proxy = GLOBAL_PROXY; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies } = pinnedDependencies(); + const proxied = captureProxiedFetch(); + try { + await providerOutboundGet( + "vendor", + { baseUrl: "https://provider.example", proxy: PROVIDER_PROXY }, + MODELS_URL, {}, dependencies, + ); + expect(proxied.calls).toEqual([`${PROVIDER_PROXY}/`]); + } finally { + proxied.restore(); + } + }); + + test("a provider proxy applies where global NO_PROXY exempts the host", async () => { + // The operator named this proxy for this provider. A global bypass list describes the + // global route and must not silently cancel the provider's own choice; the exemption that + // belongs to that choice is providers..noProxy, asserted below. + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + process.env.NO_PROXY = "provider.example"; + process.env.no_proxy = "provider.example"; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies } = pinnedDependencies(); + const proxied = captureProxiedFetch(); + try { + await providerOutboundGet( + "vendor", + { baseUrl: "https://provider.example", proxy: PROVIDER_PROXY }, + MODELS_URL, {}, dependencies, + ); + expect(proxied.calls).toEqual([`${PROVIDER_PROXY}/`]); + } finally { + proxied.restore(); + } + }); + + test("a provider noProxy match returns the request to the pinned transport", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = pinnedDependencies(); + const proxied = captureProxiedFetch(); + try { + await providerOutboundGet( + "vendor", + { baseUrl: "https://provider.example", proxy: PROVIDER_PROXY, noProxy: "provider.example" }, + MODELS_URL, {}, dependencies, + ); + expect(captured.address).toBe("93.184.216.34"); + expect(proxied.calls).toEqual([]); + } finally { + proxied.restore(); + } + }); + + test("a provider that declares nothing leaves the global decision untouched", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + process.env.https_proxy = GLOBAL_PROXY; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies } = pinnedDependencies(); + const proxied = captureProxiedFetch(); + try { + await providerOutboundGet("vendor", { baseUrl: "https://provider.example" }, MODELS_URL, {}, dependencies); + // The global decision reaches the wire exactly as it did before this field existed, + // which is what "inherit" has to mean. That decision already pins the scheme-matched + // proxy here — the fake-IP admission binds the transport to the value it assumed rather + // than letting fetch re-infer it — so the assertion is that the pin is the GLOBAL proxy + // and is unchanged, not that no pin exists. + expect(proxied.calls).toEqual([GLOBAL_PROXY]); + } finally { + proxied.restore(); + } + }); + + test("a DNS failure keeps an explicit provider proxy pinned through the degradation", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies } = pinnedDependencies({ dnsFails: true }); + const proxied = captureProxiedFetch(); + try { + await providerOutboundGet( + "vendor", + { baseUrl: "https://provider.example", proxy: PROVIDER_PROXY }, + MODELS_URL, {}, dependencies, + ); + // Re-inferring the route here would move the request to a different exit at the exact + // moment local DNS stopped working, which is when the proxy matters most. + expect(proxied.calls).toEqual([`${PROVIDER_PROXY}/`]); + } finally { + proxied.restore(); + } + }); + + test("a DNS failure under direct egress surfaces instead of degrading to an unpinned fetch", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies } = pinnedDependencies({ dnsFails: true }); + const proxied = captureProxiedFetch(); + try { + await expect(providerOutboundGet( + "vendor", + { baseUrl: "https://provider.example", proxy: PROVIDER_EGRESS_DIRECT }, + MODELS_URL, {}, dependencies, + )).rejects.toThrow(DestinationDnsResolutionError); + expect(proxied.calls).toEqual([]); + } finally { + proxied.restore(); + } + }); + + test("a malformed provider egress value refuses the request rather than choosing a route", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = pinnedDependencies(); + const proxied = captureProxiedFetch(); + try { + await expect(providerOutboundGet( + "vendor", + { baseUrl: "https://provider.example", proxy: "ftp://egress.example" }, + MODELS_URL, {}, dependencies, + )).rejects.toThrow(InvalidProviderEgressError); + // Neither degradation happened: no proxied send, and no direct send either. + expect(proxied.calls).toEqual([]); + expect(captured.address).toBeUndefined(); + } finally { + proxied.restore(); + } + }); + + test("an explicit route is refused on a caller-supplied executor instead of being dropped", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const executor = mock(async () => new Response(null, { status: 200 })); + await expect(providerOutboundGet( + "vendor", + { baseUrl: "https://provider.example", proxy: PROVIDER_PROXY, fetch: executor as unknown as typeof globalThis.fetch }, + MODELS_URL, {}, pinnedDependencies().dependencies, + )).rejects.toThrow(InvalidProviderEgressError); + // The executor owns its own routing, so running it would send the request by a route the + // configuration contradicts. + expect(executor).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/responses/provider-egress-fetch.test.ts b/tests/responses/provider-egress-fetch.test.ts new file mode 100644 index 00000000000..ff793fba0ee --- /dev/null +++ b/tests/responses/provider-egress-fetch.test.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { CODEX_RESPONSES_HTTP_URL } from "../../src/server/responses/codex-ws-request"; +import { MIN_BOUNDED_CODEX_WS_BUN_VERSION } from "../../src/server/responses/ws-upstream"; +import { InvalidProviderEgressError, PROVIDER_EGRESS_DIRECT } from "../../src/lib/provider-egress"; +import { markEgressTransparentExecutor } from "../../src/lib/provider-egress"; +import { + __resetEgressWebsocketDowngradeNotices, + providerFetch, + sendWithConnectionPolicy, +} from "../../src/server/responses/fetch-helpers"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; +import type { OcxProviderConfig } from "../../src/types"; + +/** + * The inference dispatch. Every Responses, Chat, compaction and continuation send reaches the + * wire through `providerFetch`, so this is where a per-provider route has to be applied for a + * model call rather than only for discovery. + * + * Each case asserts the proxy the request was actually pinned to. A test that only asserted a + * 200 would pass with the route dropped entirely. + */ +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + +afterEach(() => { + for (const key of proxyKeys) { + const previous = originalProxyEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + __resetEgressWebsocketDowngradeNotices(); +}); + +const TARGET = "https://api.provider.example/v1/responses"; +const PROVIDER_PROXY = "http://provider-egress.example:8080"; +const GLOBAL_PROXY = "http://global-egress.example:3128"; + +function captureDispatch(): { calls: Array<{ url: string; proxy: unknown }>; restore: () => void } { + const calls: Array<{ url: string; proxy: unknown }> = []; + const original = globalThis.fetch; + const stub = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + proxy: (init as { proxy?: unknown } | undefined)?.proxy, + }); + return new Response('{"ok":true}', { status: 200, headers: { "content-type": "application/json" } }); + }); + globalThis.fetch = stub as unknown as typeof globalThis.fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +function provider(extra: Partial = {}): OcxProviderConfig { + return { adapter: "openai-responses", baseUrl: "https://api.provider.example/v1", ...extra } as OcxProviderConfig; +} + +describe("per-provider egress on the inference dispatch", () => { + test("a provider pinned to direct sends with the runtime's explicit direct connection", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + const captured = captureDispatch(); + try { + await providerFetch(provider({ proxy: PROVIDER_EGRESS_DIRECT }), undefined, { providerName: "vendor" })( + TARGET, { method: "POST", body: "{}" }, + ); + // `false` rather than an absent option: absent falls back to HTTPS_PROXY, which is set. + expect(captured.calls).toEqual([{ url: TARGET, proxy: false }]); + } finally { + captured.restore(); + } + }); + + test("a provider proxy reaches the dispatch instead of the global one", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + const captured = captureDispatch(); + try { + await providerFetch(provider({ proxy: PROVIDER_PROXY }), undefined, { providerName: "vendor" })( + TARGET, { method: "POST", body: "{}" }, + ); + expect(captured.calls).toEqual([{ url: TARGET, proxy: `${PROVIDER_PROXY}/` }]); + } finally { + captured.restore(); + } + }); + + test("a provider that declares nothing dispatches with no proxy option at all", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + const captured = captureDispatch(); + try { + await providerFetch(provider(), undefined, { providerName: "vendor" })(TARGET, { method: "POST", body: "{}" }); + expect(captured.calls).toEqual([{ url: TARGET, proxy: undefined }]); + } finally { + captured.restore(); + } + }); + + test("the route is decided per destination, not once per provider", async () => { + // One executor, two destinations: the bypass list names one of them. Resolving the route + // when the wrapper was built instead of when the request is sent would give both the same + // exit and the second assertion would fail. + for (const key of proxyKeys) delete process.env[key]; + const captured = captureDispatch(); + const send = providerFetch( + provider({ proxy: PROVIDER_PROXY, noProxy: "internal.example" }), + undefined, + { providerName: "vendor" }, + ); + try { + await send(TARGET, { method: "POST", body: "{}" }); + await send("https://internal.example/v1/responses", { method: "POST", body: "{}" }); + expect(captured.calls.map(call => call.proxy)).toEqual([`${PROVIDER_PROXY}/`, false]); + } finally { + captured.restore(); + } + }); + + test("an explicit route moves the WebSocket fast lane onto HTTP rather than dialling past it", async () => { + // The WebSocket upstream picks its proxy from the process environment when it dials, so it + // cannot carry a per-provider route. Serving the turn over HTTP honours the operator's + // choice; dialling anyway would send it out the global exit while the configuration says + // otherwise. The downgrade is announced, because a transport change nobody asked for is + // exactly the kind of substitution that must not be silent. + for (const key of proxyKeys) delete process.env[key]; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + const captured = captureDispatch(); + const streamingPost = { method: "POST", body: JSON.stringify({ stream: true }) } as const; + try { + const send = providerFetch( + provider({ proxy: PROVIDER_PROXY }), + MIN_BOUNDED_CODEX_WS_BUN_VERSION, + { providerName: "vendor" }, + ); + await send(CODEX_RESPONSES_HTTP_URL, { ...streamingPost }); + await send(CODEX_RESPONSES_HTTP_URL, { ...streamingPost }); + expect(captured.calls.map(call => call.proxy)).toEqual([`${PROVIDER_PROXY}/`, `${PROVIDER_PROXY}/`]); + // Announced once per provider per process, not once per request. + expect(warnings.filter(line => line.includes("vendor"))).toHaveLength(1); + } finally { + captured.restore(); + console.warn = originalWarn; + } + }); + + test("an explicit route is refused on a caller-supplied executor instead of being dropped", async () => { + for (const key of proxyKeys) delete process.env[key]; + const executor = mock(async () => new Response(null, { status: 200 })); + const configured = provider({ proxy: PROVIDER_PROXY }) as OcxProviderConfig & { fetch?: typeof globalThis.fetch }; + configured.fetch = executor as unknown as typeof globalThis.fetch; + // The hook must not run either: it commits attempt accounting and consumes admission state, + // so charging an attempt for a send that is about to be refused would misreport the attempt + // and could mask the egress error behind an unrelated throw. + const beforeDispatch = mock(() => undefined); + await expect(providerFetch(configured, undefined, { providerName: "vendor", beforeDispatch })( + TARGET, { method: "POST", body: "{}" }, + )).rejects.toThrow(InvalidProviderEgressError); + expect(beforeDispatch).not.toHaveBeenCalled(); + expect(executor).not.toHaveBeenCalled(); + }); + + test("a malformed egress value rejects the send rather than falling back to a route", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = GLOBAL_PROXY; + const captured = captureDispatch(); + try { + await expect(providerFetch(provider({ proxy: "ftp://egress.example" }), undefined, { providerName: "vendor" })( + TARGET, { method: "POST", body: "{}" }, + )).rejects.toThrow(InvalidProviderEgressError); + expect(captured.calls).toEqual([]); + } finally { + captured.restore(); + } + }); + + test("a rebuilt request resolves its route against the destination it is actually sent to", async () => { + // A queued request can be rebuilt at its physical send -- account reselection can move the + // upstream host -- so a route decided when the executor was constructed would be applied to + // a host it was not decided for. Here the bypass list names only the rebuilt destination: + // resolving early would send it through the proxy, and the credential would leave by a + // route the operator excluded. The same class as #4992, which is why the decision now sits + // at the same boundary as the connection policy. + for (const key of proxyKeys) delete process.env[key]; + const captured = captureDispatch(); + const rebuiltUrl = "https://internal.example/v1/responses"; + try { + await providerFetch( + provider({ proxy: PROVIDER_PROXY, noProxy: "internal.example" }), + undefined, + { + providerName: "vendor", + dispatchOverride: (_input, init, execute) => execute(rebuiltUrl, init), + }, + )(TARGET, { method: "POST", body: "{}" }); + expect(captured.calls).toEqual([{ url: rebuiltUrl, proxy: false }]); + } finally { + captured.restore(); + } + }); + + test("an internal wrapper that forwards its init still carries the route", async () => { + // Not every `provider.fetch` owns a transport. The xAI route installs a wrapper that only + // adds a header and delegates; refusing those would make the per-provider proxy unusable on + // one of the two providers the original issue names. The marker is opt-in, so an executor + // arriving from configuration stays opaque and is still refused. + for (const key of proxyKeys) delete process.env[key]; + const seen: Array = []; + const wrapper = markEgressTransparentExecutor((async (_input: RequestInfo | URL, init?: RequestInit) => { + seen.push((init as { proxy?: unknown } | undefined)?.proxy); + return new Response(null, { status: 200 }); + }) as unknown as typeof globalThis.fetch); + const configured = provider({ proxy: PROVIDER_PROXY }) as OcxProviderConfig & { fetch?: typeof globalThis.fetch }; + configured.fetch = wrapper; + await providerFetch(configured, undefined, { providerName: "vendor" })(TARGET, { method: "POST", body: "{}" }); + expect(seen).toEqual([`${PROVIDER_PROXY}/`]); + }); + + test("an override that drives the physical boundary itself keeps an ordinary provider routable", async () => { + // The production shape: `dispatchOverride` calls the connection policy with + // `provider.fetch ?? execute` and its own binding, so `execute` -- the executor this module + // supplies -- becomes the selected transport. Treating that wrapper as caller-owned would + // refuse every configured provider on this path, and only after the attempt was recorded, + // which is precisely the failure an assertion on the returned status cannot see. + for (const key of proxyKeys) delete process.env[key]; + const captured = captureDispatch(); + const configured = provider({ proxy: PROVIDER_PROXY }); + try { + const response = await providerFetch(configured, undefined, { + providerName: "vendor", + dispatchOverride: (input, init, execute) => + sendWithConnectionPolicy(execute, input, init, { providerName: "vendor", provider: configured }), + })(TARGET, { method: "POST", body: "{}" }); + expect(response.status).toBe(200); + // Decided once, by the boundary that knows the final destination. + expect(captured.calls).toEqual([{ url: TARGET, proxy: `${PROVIDER_PROXY}/` }]); + } finally { + captured.restore(); + } + }); + + test("the outermost boundary owns the decision when a reselected provider differs", async () => { + // Reselection can replace the provider mid-dispatch, so the override's binding is fresher + // than the one captured when the wrapper was built. The inner pass must defer to it rather + // than re-deciding from the stale closure and overwriting the route. + for (const key of proxyKeys) delete process.env[key]; + const captured = captureDispatch(); + const staleProvider = provider({ proxy: PROVIDER_PROXY }); + const reselected = provider({ proxy: PROVIDER_EGRESS_DIRECT }); + try { + await providerFetch(staleProvider, undefined, { + providerName: "vendor", + dispatchOverride: (input, init, execute) => + sendWithConnectionPolicy(execute, input, init, { providerName: "vendor", provider: reselected }), + })(TARGET, { method: "POST", body: "{}" }); + expect(captured.calls).toEqual([{ url: TARGET, proxy: false }]); + } finally { + captured.restore(); + } + }); +}); diff --git a/tests/responses/responses-fetch-helpers-boundary.test.ts b/tests/responses/responses-fetch-helpers-boundary.test.ts index 787ffe6e286..e2f63efeacd 100644 --- a/tests/responses/responses-fetch-helpers-boundary.test.ts +++ b/tests/responses/responses-fetch-helpers-boundary.test.ts @@ -45,7 +45,9 @@ function expectRuntimeImportBoundary(source: string): string[] { describe("Responses fetch-helper import boundary", () => { test("loads only transport-owned runtime dependencies", () => { expect(expectRuntimeImportBoundary(readFileSync(helperPath, "utf8"))).toEqual([ + "../../lib/provider-egress", "../../lib/proxy-env", + "../../lib/redact", "../../lib/upstream-http-version", "../../providers/request-pacing", "./ws-upstream", diff --git a/tests/server/provider-egress-management-validation.test.ts b/tests/server/provider-egress-management-validation.test.ts new file mode 100644 index 00000000000..5c0db7fe2d6 --- /dev/null +++ b/tests/server/provider-egress-management-validation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { REDACTED_PROVIDER_FIELDS, providerManagementConfigError } from "../../src/server/auth-cors"; +import { PROVIDER_EGRESS_DIRECT } from "../../src/lib/provider-egress"; + +/** + * The management write boundary for the per-provider egress fields. + * + * A sibling of `management-provider-validation.test.ts` rather than an addition to it: that + * file sits at its recorded line cap, and the cap only ever moves downward. + */ +function validate(provider: Record): string | null { + return providerManagementConfigError("vendor", { adapter: "openai-responses", baseUrl: "https://provider.example/v1", ...provider }); +} + +describe("provider egress at the management write boundary", () => { + test("the accepted forms are admitted", () => { + for (const proxy of [PROVIDER_EGRESS_DIRECT, "http://egress.example:3128", "https://egress.example:3129", "socks5://127.0.0.1:1080", null]) { + expect(validate({ proxy })).toBeNull(); + } + expect(validate({ noProxy: "internal.example" })).toBeNull(); + expect(validate({ noProxy: ["internal.example", "10.0.0.1"] })).toBeNull(); + expect(validate({})).toBeNull(); + }); + + test("an unusable value is rejected at the write rather than at the first request", () => { + for (const proxy of ["", " ", "not a url", "ftp://egress.example", "socks4://127.0.0.1:1080"]) { + expect(validate({ proxy })).not.toBeNull(); + } + expect(validate({ noProxy: [42] })).not.toBeNull(); + }); + + test("a rejection never echoes the value, because a proxy URL carries credentials", () => { + // A `.test` host: a credentialed proxy URL reads as `password@host` to the privacy scanner, + // and that domain is on its allowed list for fixtures. + const error = validate({ proxy: "ftp://operator:hunter2@egress.test:3128" }); + expect(error).not.toBeNull(); + for (const fragment of ["operator", "hunter2", "egress.test"]) { + expect(error).not.toContain(fragment); + } + }); + + test("the proxy field is classified as credential-bearing and never leaves in a DTO", () => { + // A proxy URL routinely embeds `user:password@`, so it is redacted like `apiKey` and the + // dashboard editor may not write it. Asserting the classification here is what keeps a + // later reclassification from quietly publishing the credential. + expect(REDACTED_PROVIDER_FIELDS).toContain("proxy"); + expect(REDACTED_PROVIDER_FIELDS).not.toContain("noProxy"); + }); +}); From 38a5ab9fc4eaa223d3d58982b0bc097279138fca Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:04:18 +0900 Subject: [PATCH 17/87] feat(macos): menu bar companion + widget, with CodexBar-style usage monitor integrated into Usage (#5196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(devlog): plan macOS menu bar companion (Phase 0 roadmap) Nine numbered docs covering the roadmap for a maintainer-owned macOS menu bar app in app/, consolidating the two competing community PRs (#387 Swift/SwiftUI, #421 Tauri/React). - 000_plan: constraints, dependency-ordered phase map, accept criteria - 001_pr_survey: head-to-head of both PRs; stack decision is Swift/AppKit runtime with HTTP management-API transport, plus the salvage list - 002_api_surface: live payload inventory, the seconds-vs-milliseconds quota timestamp trap, and the default-provider 400 trap - 003_design_read: Design Read and dial lock (V2/M1/D7), inheriting the existing gui/src/styles.css tokens - 010-050: diff-level decade docs, one per implementation phase * docs(devlog): fold 13 audit blockers into the macOS app roadmap Adversarial Phase-0 review returned FAIL. Corrections, all verified against live source and the running proxy: - /api/stop calls stopServiceIfInstalled() before responding, so nothing restarts the proxy. The app now ships Stop proxy, never Restart, and never spawns a process. - /api/usage supports only 7d/30d/all; 24h silently degrades to 30d. The range is now a closed enum and the UI labels the range the response returned, not the one it requested. - defaultProvider lives on /api/config, not /api/settings. Added the model, the client method, and the test. - The bundle script now defines every path before use and copies Info.plist before plutil; it could not have run as previously written. - release.yml grants contents:write and id-token:write at workflow level, so the new jobs declare explicit least-privilege permissions. Added a separate attach-macos job so packaging can never block the npm publish, and pinned both new actions to full SHAs. - Re-surveyed PR #421 at head 049ef2ac: the committed src-tauri/target tree was already removed by the contributor. The closing comment must credit that fix rather than repeat a stale defect. - /api/logs exists for per-request activity; documented as a deliberate v1 exclusion instead of an implicit gap. - Phase 1 no longer claims verification via a Phase 4 script. - Added StartupHealth service fields, security-review acceptance evidence to Phase 4, and removed developer-absolute paths from tracked docs. * docs(devlog): fold round-2 audit blockers (design lock, bundle ownership, plist) Round-2 adversarial review returned FAIL on 9 findings, most of them caused by round-1 edits that corrected prose without correcting the specs those documents actually lock. - 003 was never touched in round 1, so the design lock still mandated a 24h sparkline and a Restart button that 002/030 prove impossible. Wireframe now shows LAST 7 DAYS and Stop proxy. - release.yml's input is named dry-run, not dry_run. inputs.dry_run would resolve to null and the attach-macos guard would silently pass during a dry run — the exact failure that guard exists to prevent. - Bundle ownership was relocated, not resolved: Phase 2 claimed a launchable .app while the builder stayed in Phase 4. Phases 1-3 now verify through swift test/build/run; Phase 4 owns the bundle end to end. - app/Info.plist was missing CFBundleExecutable, CFBundlePackageType, and CFBundleIconFile, so the specified bundle would not have launched. - Re-read #421 at head 049ef2ac: menubar/src/api.ts:12-13 returns the token into renderer memory, so the PR's isolation claim does not hold. Removed that credit from both the survey and the planned closing comment, and removed the remaining stale rejection sentences. - Scoped the absolute-path criterion to files this unit touches; unrelated historical devlogs already contain such paths. - loading is now explicitly exempt from the next-action rule, and per-section empty states are defined with their own copy. - The range-fallback test injects a stubbed response, since the closed UsageRange enum makes the curl path unreachable from production code. * docs(devlog): clear round-3 residual nits Round-3 audit returned GO-WITH-FIXES (blockers=0). Cleared all three: - 010 still called the first bundle a Phase-2 deliverable, contradicting 000/020/040. Phase 4 owns the bundle end to end. - 001's salvage list still credited #421 with renderer-side token isolation, contradicting its own verified analysis, and claimed all four surfaces were adopted when per-request activity was deliberately excluded. - 040 and 050 still scoped the absolute-path rule to every tracked file, which pre-existing historical devlogs already violate. Both now match 000's unit-scoped wording. * feat(app): add macOS menu bar core — discovery, client, formatting Phase 1 of the macOS companion (010_phase1_core.md). Zero third-party dependencies; AppKit and Foundation only. - Discovery resolves the proxy from OPENCODEX_HOME/runtime-port.json with a 10100 fallback. The host is pinned to loopback and never read from the record, so a file write cannot redirect the app at another host. - ProxyModels mirror the live payloads. QuotaReport.normalized() absorbs two real traps: the window key differs per provider (weekly/monthly/custom), and weeklyResetAt arrives in seconds from openai but milliseconds from anthropic within the same array, so timestamps are disambiguated by magnitude. - UsageRange is a closed enum because the server silently degrades an unrecognized range to 30d; UsageReport.rangeLabel is derived from the response so the UI can never label 30 days of data as something else. - ProxyClient is an actor. ProxyError carries human sentences only, never a response body, since bodies can echo configuration. - Format renders an em dash for unknown and a real zero for zero; the live proxy reports 3.6e10 tokens, so everything is abbreviated. Testing is an executable target rather than a .testTarget: Command Line Tools resolves neither XCTest (module not found) nor the swift-testing runtime (Testing.framework fails to dlopen). Requiring full Xcode to run these tests would exclude most contributors. 31 cases pass via `swift run --package-path app MenuBarCoreTests`. Verified live against the running proxy: endpoint discovery, health (at-risk, service-managed), defaultProvider=openai from /api/config, 7-day usage (44.5K requests, 7.34B tokens, $6.15K), and four provider quotas with correctly resolved reset windows. * fix(app): fold code-review blockers into the menu bar core Adversarial review of c7fbf57c returned FAIL on 10 findings. All verified against the live proxy or Apple docs before folding. - Info.plist: add NSAllowsLocalNetworking. macOS 14 stopped allowing IP loads under ATS, so the packaged bundle could not reach 127.0.0.1 at all while swift run stayed green — the app's primary function, broken only in the artifact users would actually download. - ProxyClient: wire the lazy Keychain retry that the plan required and the code never implemented. A CredentialStore protocol is injected, the key loads once, and exactly one retry follows a 401 so a stale key cannot spin. - Keychain: set kSecUseDataProtectionKeychain on every query, without which kSecAttrAccessible is ignored on macOS; tighten to ThisDeviceOnly; update before add so a failed add cannot destroy a working key. - ProxyModels: live kimi reports fiveHourPercent alongside weeklyPercent, and cursor and google-antigravity each carry two customWindows. Added the five-hour fields and normalizedWindows() returning every window; normalized() keeps an explicit longest-horizon precedence. - isEmptyOrUnknown preserves three states so an omitted request count cannot render as "No requests". - Cancellation now propagates instead of reading as a stopped proxy, and unrelated transport failures get their own .transport case. - ProxyEndpoint is failable; baseURL is built once instead of force-unwrapped. - Format promotes at rollover: 999_999 renders 1.00M, not 1000K. - TransportSuite adds 14 cases over status mapping, the 401 retry path, cancellation, request shape, and body redaction. 51 pass, 0 fail. - Harness no longer counts a case as passed when it recorded a failure. Live re-verification across all six providers: Kimi 5h+week, Cursor's three windows, and correct primary-window selection for each. * fix(app): per-request 401 retry and pressure-based quota selection Round-2 code review found two real defects, both semantic rather than syntactic, and both proven with gated probes. - Concurrent initial 401s produced a false authorization failure. The actor suspends across each request, so two calls can both receive 401; the first loaded the key and retried while the second saw the global didAttemptCredentialLoad flag and threw .unauthorized even though a usable key now existed. Retry eligibility is now decided per request against the key that request actually sent, so a caller that started before the load still retries with it, and a caller that already used the current key does not loop. Actor isolation prevented data races here but not reentrancy. - The compact quota row preferred the longest horizon, which could hide the window actually blocking the user: 99% of a five-hour limit alongside 10% monthly rendered as a green 10%. Selection is now highest reported usage, with ties breaking toward the longer horizon since that one does not recover on its own. Live proof: Cursor's row moved from month=10% to API usage=42%, which the previous logic concealed. Regressions: a gated concurrent-401 case asserting two successes, exactly one credential load, and four total requests; plus pressure-selection cases for higher-short-window, tie-break, and unmeasured-window inputs. 51 -> 55 cases, all passing. Live re-verified across all six providers. * feat(app): add menu bar status item and popover UI Phase 2 (020_phase2_ui.md). AppKit rather than SwiftUI: this is a fixed-width column of rows, which stack views do without fighting NSPopover sizing. - ProxySnapshot is the single source the views render from, so no view invents its own loading flag. Five states, and every one carries a word beside its dot so meaning is never colour-only. - PollingCoordinator implements the 002 contract: 5s liveness always, heavy aggregation only while the popover is open, 30s backoff after three consecutive failures. Cancellation is not treated as a failure. - Theme derives from gui/src/styles.css but prefers AppKit semantic colours where they exist, since those also track increased-contrast and vibrancy. Numerics use monospaced digits so polling does not make digits jitter. - The menu bar glyph is a vector template image and carries state through fill and a notch, not colour: a coloured dot in the menu bar is the tell of an app that ignores the platform. - Quota rows show which window each percentage belongs to. Without it, 42% of Cursor's API-usage window and 42% of a month look identical. - A nil percent draws no bar at all, because a zero-width bar reads as "0% used" — a different fact from unknown. Visual verification drove three fixes that code review would not have caught: the sparkline rendered as wide slabs that read as a progress bar rather than a chart; the trend was centred and floated away from the columns it belongs to; and hidden sections left a large empty void because the view kept its initial 260pt instead of sizing to content. Screenshots of running, stopped, unauthorized, degraded, and empty were inspected in the real window server. UI moved into a MenuBarUI library so the visual-QA probe can build the same surface — an executable target cannot be imported. 55 -> 64 test cases. * fix(app): fold UI review blockers — states, keyboard, polling, glyph Adversarial review rendered every state and returned FAIL on 9 findings. - Stop proxy now confirms first. It interrupts in-flight requests and stops launchd, so firing it on a single click was wrong. - Escape did not work at all: an accessory app never takes key focus, so keyDown never arrived. Now activates on open, sets a first responder, and installs a scoped key monitor that is removed on close. - Loading, unauthorized, and degraded were specified but not built. Loading shows skeleton rows with disabled chrome; unauthorized has a real Add key button; degraded keeps its last-known data with an explicit age plus Retry, because stale-but-labelled beats a blank panel. - Polling split into on-open reads (providers, config) and interval-gated aggregation (usage, quotas). Previously every open forced aggregation while background ticks fetched on-open data — exactly backwards. - Refreshes can no longer overlap or outlive a close: one in-flight cycle, a generation counter that discards superseded results, and freshness advanced only when the aggregation actually completed. - The at-risk notch never rendered. Stroking with .clear under a .clear composite silently did nothing, so a protected and an at-risk proxy showed an identical glyph — the state signal was invisible. Carved with even-odd winding and verified against a rendered glyph sheet. - recommendedCommand was decoded but never displayed; the live proxy has been advising ocx service install this whole time. Now shown as selectable text, alongside a provider summary line. - Popover height is capped at 480pt with a scrolling body, and scrollers appear only on real overflow. - PollingSuite replaces a test that asserted four constants: gating, cadence, backoff, recovery, degraded retention, and observer delivery. 64 -> 73. UIProbe captures via CGWindowListCreateImage so nothing under app/ constructs a Process, per the 030 security rule. * fix(app): make Escape work, top-anchor overflow, split data freshness Round-2 UI review found 6 defects, all reproduced before folding. - Escape genuinely did not work. Activating before presentation leaves an accessory app's popover without key focus, so no key event ever arrived. Activation now happens on the next main-loop turn after show(relativeTo:). Verified by synthesizing keycode 53 into the app's own event queue: popover shown true before, false after. - Overflowing content opened scrolled to the bottom, hiding the status line and metrics that the urgency order exists to surface. NSScrollView is bottom-origin by default; a flipped clip view fixes it. - Close-then-immediate-reopen dropped the reopen's refresh: the old cycle exited on its generation guard while the new one had already been rejected by the in-flight lock. Refreshes now queue and drain on every exit path. - Closing mid-sequence still paid for later requests, and a partial aggregation failure re-fetched its healthy sibling every 5 seconds because the rate limit keyed on success. Now every request re-checks the cycle, and aggregation is limited by attempt. - Retry opened a browser. Add key and Retry now have separate callbacks. - Degraded quoted an age derived from the last health probe, so it could claim to be showing data it never loaded. healthUpdated and usageUpdated are now separate, showsData requires actually-loaded sections, and the guidance quotes the data age. The overflow menu ships Refresh, Open dashboard, and Quit rather than the sketched Preferences: there is no preferences surface, and a menu item that opens nothing is worse than its absence. Spec amended to match. * fix(app): replace NSPopover with a key-capable panel so Escape works Three rounds of Escape fixes failed because the premise was wrong, not the implementation. Probing the real delegate from an accessory process: popover window in NSApp.windows : absent canBecomeKey : false after NSApp.activate : appActive=true, isKey=false after NSRunningApplication : appActive=true, isKey=false after raising the window level : appActive=true, isKey=false macOS does not route key events to a window that cannot become key, so no activation strategy could ever have delivered Escape. PopoverPanel measures shown=1 canBecomeKey=1 isKey=1, and Escape closes it. The panel keeps the parts of the popover contract that matter: transient dismissal on outside click, dismissal on losing key focus, and nonactivatingPanel so opening does not steal focus from the user's editor. Also fixed: - The success path's generation guard returned without draining a queued reopen, so close-then-reopen still dropped its refresh. Every exit path now clears the lock and drains. - On-open reads ran on every 5s liveness tick, turning two rarely-changing endpoints into pollers. Now gated on an actual open or manual refresh. - An already-invalid cycle could consume the aggregation window and make a legitimate reopen skip usage and quotas for 60 seconds. - Removed lastHeavyRefresh and healthUpdated, written but never read. Four new polling tests: tick-while-open, closed-popover, partial aggregation failure, and degraded-without-data. 73 -> 77. * fix(app): give the panel a real surface and keep it alive behind the alert Round-4 review found two defects introduced by the NSPopover -> NSPanel amendment. Both are things NSPopover had been providing for free. - The borderless panel had no background at all. isOpaque=false with a clear backgroundColor composited the whole dashboard onto whatever application was underneath: labels collided with the app behind it, and contrast depended on that app's colours. Content is now wrapped in an NSVisualEffectView with .popover material, rounded and clipped. - Presenting the Stop confirmation made the alert key, which tripped resignKey() and dismissed the panel behind it. A user who chose Cancel was returned to nothing. isPresentingModal now suspends resign-key dismissal; Cancel restores key focus and Confirm dismisses deliberately. UIProbe missed the first defect because it rendered the controller inside an ordinary NSWindow, which supplies its own background. It now presents through the real PopoverPanel over a loud backdrop, so a missing surface cannot hide. That is twice in this phase that the harness rather than the code was concealing a defect. Also: dismiss() is idempotent against a late monitor callback, debugTogglePanel() is #if DEBUG only, and applicationWillTerminate dismisses the panel. * fix(app): let the alert own Escape, and fix measured contrast failures Round-5 review found two defects, both verified by measurement. - Escape during the Stop confirmation dismissed the panel and consumed the event, leaving the alert stranded with no keyboard way to cancel. The monitor now returns the event unchanged while isPresentingModal, so NSAlert handles Escape as Cancel. - Theme.faint used tertiaryLabelColor, which measured 2.01:1 in light and 2.39:1 in dark against the popover material — far under the 4.5:1 required for text. AppKit's tertiary tier is meant for disabled affordances, but it was carrying the range heading, metric captions, and quota window labels: information the user has to read. Replaced with calibrated tokens plus a separate graphMark token held to the 3:1 non-text threshold. Re-measured from the rendered PNG: 7.27:1 light, 4.98:1 dark. Contrast is now measured rather than assumed from token names, and UIProbe can force an appearance without touching system settings. * fix(app): recalibrate all four text tiers against the rendered material My round-5 contrast correction was itself wrong. The sampling took the darkest pixel in a band, which is primary text, not faint — so 7.27:1 and 4.98:1 described a token that was never in question while the actual faint tier sat at 2.87:1 in dark and the sparkline marks at 1.85:1. Corrected method: count pixels matching each exact token value in the rendered PNG, so one tier cannot be measured by accidentally sampling another. Measured against light (220,219,218) and dark (103,102,102): text 12.59 / 5.72 (>= 4.5) muted 7.86 / 5.11 (>= 4.5) faint 5.48 / 4.89 (>= 4.5) graphMark 3.58 / 3.79 (>= 3.0, non-text) All four pass and text > muted > faint holds in both appearances. The light inversion the reviewer found — faint outranking muted — is gone. The dark material is the binding constraint: pure white measures only 5.81:1 against it, so three text tiers have to fit inside a 1.3-point band. That is why the dark values cluster, and why AppKit's semantic tiers cannot be used here without silently reintroducing the failure. * docs(app): note the material pixel variation and the semantic-colour exception Round-7 review passed. Two documentation nits from it: - The dark popover material is not perfectly flat: the dominant pixel is (102,101,101) while adjacent pixels read (103,102,102). The contrast table uses the lighter value (the stricter test) and the 5.81:1 ceiling comes from the darker one. Both are now named. - Theme's header claimed AppKit semantic colours always win, which is true for surfaces but is now a deliberate exception for the text tiers. * feat(app): wire proxy control and provider toggles Phase 3 (030_phase3_actions.md). The client write methods and the confirmation sheet already landed in Phase 2 — a Stop button could not ship without them — so this phase adds what was actually missing: outcome reporting, the provider toggle UI, and result feedback. - ActionCoordinator reports what happened rather than what was requested. /api/stop answers before it drains and stops launchd on the way, so a 200 means accepted, not stopped: the coordinator polls until the port stops answering and reports requiresManualStart with the command for that install. A proxy still answering after 10s is a failure, not a success. - Provider toggles are optimistic with revert on rejection. The default provider's switch is inert and explains why, since the proxy answers 400 for that case and firing a request that cannot succeed is worse than not offering it. - A result banner reports every write outcome and clears itself, guarded by a token so an older timer cannot clear a newer result. - No failure path quotes a response body; bodies can echo configuration. The stop timeout test needed an injectable clock, not just a no-op sleeper: the loop is bounded by a deadline, so skipping the sleep without advancing time meant it never expired and the test reported success. Recorded in 030 along with the stub's drain-to-refused fallback, which can make an under-queued test pass for the wrong reason. Live-verified against the running proxy: anthropic disabled and re-enabled with the proxy confirming each state, and the default-provider guard refusing before any request. Proxy state restored afterwards, 10 of 10 enabled. 77 -> 87 tests. * fix(app): distinguish liveness states and serialize provider writes Review of ef1c59c5 returned FAIL on 6 findings, all verified against the proxy source. - isReachable() treated every non-401 error as "gone", so a 500 or a decode failure while polling after /api/stop reported the stop as confirmed while an HTTP server was still listening. Replaced with three-state liveness: reachable (any HTTP answer proves the port is occupied), refused (the only proof the proxy is gone), indeterminate (a timeout proves nothing). - /api/stop returns success:false when restoreNativeCodex() fails (management-api.ts:145-147). The proxy still exits, but native Codex is left pointing at a closing port. The body was discarded, so the app said "Proxy stopped". Now decodes only the boolean — never the server's message — and reports stoppedWithRestoreFailure telling the user to run ocx restore. - Two rapid toggles could reach the server out of order and leave it opposite to the user's last click, since both actors are reentrant across awaits. One in-flight write per provider, and the row stays inert until its authoritative refresh lands. Pending state survives rebuildRows so a poll cannot resurrect the pre-toggle switch. - A default provider that was already disabled could never be re-enabled: the switch was inert whenever isDefault, but the proxy guard fires only when disabled is true AND the name matches the default — enabling is valid. - The "exact body" test encoded its own dictionary rather than reading the request, so it would have passed with no body at all. StubProtocol now drains httpBodyStream and the test asserts on the decoded actual body. - Acceptance criterion 1 demanded a live stop while the notes said stop was deliberately not run live. Amended with reasoning: stopping the developer's proxy is out of bounds, and the branches that matter cannot be produced on demand from a healthy proxy. Also corrected 002 (the success flag was undocumented) and 050's stale "scroll-free column". 87 -> 93 tests. * fix(app): only a refused connection proves the proxy stopped Round-2 review found the three-state liveness contract was still two states in practice, plus three follow-on defects. - perform() mapped .timedOut, .networkConnectionLost, .cannotFindHost, and .notConnectedToInternet to ProxyError.unreachable, which liveness() then read as .refused. So a timeout during the stop poll could still confirm a stop while the proxy was running — the exact defect round 1 was meant to fix. Added ProxyError.inconclusive; only .cannotConnectToHost becomes .refused now. Liveness probes also take a 1.5s timeout so a single probe cannot overrun the 10s stop deadline it is supposed to respect. - rebuildRows() initialised each switch from the server snapshot, so a poll landing mid-write snapped the switch back to its pre-toggle value even though the row was marked busy. pending now stores the intended state and applies it before marking the row busy. - The post-write refresh coalesced: refresh() queues and returns immediately when another cycle holds the lock, so the switch became interactive again against pre-write data. Added refreshAndWait(). - 030 still demanded a live stop in its verification line and carried three pre-review snippets (void stop(), boolean isReachable() loop, unconditional default guard) that would have reintroduced the reviewed defects. Added liveness classification tests for every URLError code that matters, an HTTP-answer table (200/401/403/500 all prove the port is occupied), an undecodable-200 case, and a stop-with-timeout case asserting the inconclusive message rather than a false success. 93 -> 97 tests. Also corrected the 002 stop snippet, which showed only the success:true branch while the prose below it described both. * fix(app): single-attempt liveness and a real refresh completion signal Round-3 review returned GO-WITH-FIXES on two Medium blockers. - liveness() went through the generic send(), so a 401 with a stored key triggered the credential retry: a second full timeout spent re-asking a question the 401 had already answered, and a failed retry downgraded a known-reachable result to indeterminate. It now calls perform() directly — one attempt, no retry. - The stop loop always asked for a 1.5s probe regardless of time remaining, so the final probe could overrun the 10s deadline. Each probe is capped to min(1.5, remaining) and the loop breaks when nothing is left. - refreshAndWait() spun on shared booleans with a 5s bound. A legitimately slow cycle (providers + config sequentially, plus a due aggregation) can exceed that, at which point it returned and the switch became interactive against pre-write data — the exact window the method was added to close. It now waits on a continuation released when no cycle is running or queued. Also corrected two stale ProxyError doc comments (timeout is no longer unreachable, DNS is no longer transport) and the ActionOutcome snippet in 030, which predated stoppedWithRestoreFailure. New tests: a 401 with a stored key resolves in one request; the probe honours a caller-supplied timeout; every stop probe stays within the cap; refreshAndWait returns only after a cycle published, and survives a failing cycle without hanging. 97 -> 102. * test(app): actually exercise the refresh continuation path Round-4 review found that neither refreshAndWait test entered the code they were written to protect. Both ran with refreshInFlight == false, so they took the direct path and never touched completionWaiters, waitForCompletion, or signalCompletionIfIdle. They would have stayed green if the continuation never resumed, resumed early, or was deleted. StubProtocol gained a request gate so a cycle can be held suspended. Two new tests start a refresh, block it in the stub, call refreshAndWait concurrently, assert it has NOT returned, then release and assert it does. One covers a succeeding queued cycle, one a failing cycle. Sabotage-verified, because a passing test proves nothing about a path it never takes: removing the resume line made the suite hang until the 120s timeout rather than pass. Restored, it completes in about 2 seconds. 102 -> 104 tests. * test(app): make the failing-cycle test actually consume a failure Round-5 review found the "queued cycle fails" test was re-testing the success path. With the popover closed a cycle consumes exactly one health response, and the queue led with three 200s, so the connection-refused responses were never reached. It would have stayed green if the error exit stopped signaling waiters. Two contract details drive the corrected setup: drainPendingRefresh only runs while the popover is open, and an open cycle consumes health + providers + config + usage + quotas. So the popover is opened first, then a single gated 200 lets cycle 1 reach the gate, and everything after is a refusal. A new snapshot.state == .unreachable assertion proves the failure was consumed — and that assertion is what caught the original defect. Hardened the gate harness alongside it: setGate/currentGate now go through the stub's existing lock rather than racing on a bare static, a gateEntered semaphore lets a test wait for the request to actually arrive instead of inferring it from a 200ms sleep, and defer releases the gate so a mid-test failure cannot wedge the suite. Sabotage results, both recorded in 030 because the second one matters: removing waiter.resume() entirely hangs the suite, so the gate tests do depend on the continuation. Removing only the ProxyError signal does not fail it — a signal trace showed the waiter is protected by several exit paths, so single-site sabotage is not a valid probe here. 104 tests. * test(app): deterministic waiter registration and a UI test target Round-6 review found two ways the suite could pass without proving anything. - The continuation tests synchronised on a fixed sleep. gateEntered proved cycle 1 reached the gate, but nothing proved the waiter had registered before the gate was released; under starvation the waiter could start afterwards, take the ordinary non-coalesced path, and still satisfy every assertion. PollingCoordinator now exposes waiterCount, and the tests poll it until registration is observed, then assert it returns to zero. - No test drove MenuBarUI at all. The Phase 3 behaviours that had actually been defects in earlier rounds — optimistic rollback, pending state surviving a stale poll, and the direction-sensitive default guard — had no regression cover, because MenuBarCoreTests depends only on MenuBarCore. Added a MenuBarUITests target with read-only inspection hooks. Sabotage-verified: reintroducing both original defects failed exactly the two matching cases and left the other five green. Making the default guard direction-insensitive failed the disabled-default recovery test; dropping the intended value in rebuildRows failed the stale-poll test. Also removed an unnecessary nonisolated(unsafe) on a let constant. 104 core + 7 UI tests. * chore(app): narrow test hooks to package visibility Round-7 review passed. Carry-forward items folded now rather than deferred: - waiterCount and the ProviderListView test hooks are `package` rather than `public`. Neither module ships as a library product, so this was never an external API risk, but package visibility says what these are: test-only access within the package. - 040's test:macos script runs both suites, and its acceptance criteria now state that "build clean" means exit 0 rather than warning-free, since the remaining warnings are Command Line Tools search paths from the toolchain. - 030's stop example carries the remaining-time clamp that shipped. * feat(release): build and package the macOS companion Phase 4 (040_phase4_release.md). The app now has a distribution path, which is what the whole question was about: a menu bar app a user has to compile is not a shipped app. - scripts/build-macos-app.sh assembles OpenCodex.app by hand — no Xcode project to keep in sync. It stages into a temp directory and moves at the end, so an interrupted build cannot leave a half-written bundle that launches and misbehaves. Version comes from package.json, so the app can never claim a version the release did not ship. UNIVERSAL=1 under Command Line Tools refuses with an explanation instead of a linker error. - scripts/package-macos-release.sh asserts rather than hopes: codesign --verify --deep --strict, lipo arch check, ditto archiving (plain zip corrupts the signature), an archive-contents assertion, and a SHA-256 sidecar. - release.yml gains package-macos and attach-macos. Workflow-level permissions drop to {} and each job declares its own, so a new job cannot silently inherit a write token or an OIDC credential. package-macos has no needs relationship with publish in either direction: a Swift failure must never be able to block an npm release. - ci.yml runs the macOS test and build on macOS runners only, after privacy:scan so a credential leak fails before a multi-minute Swift build. The path filter gained app/** — without it an app-only change ran no CI. Verified locally end to end: the bundle builds, passes codesign, launches with no ATS errors, packages to an 813 KB zip whose checksum verifies, and survives unpack-and-launch — the path a user actually takes, and the one that would expose a corrupted signature. One debugging note recorded in 040: the archive assertion originally used `unzip -Z1 | grep -Fqx`, which fails under pipefail because grep -q exits on match and unzip dies on SIGPIPE. It rejected correctly-packaged archives. * fix(release): env-pass the release input, fix preview versions, guard output Security review returned FAIL on three findings. The first was caught by the repository's own regression suite, which is the best possible outcome. - release.yml interpolated inputs.version directly into run: shell source. tests/ci-workflows.test.ts:76-81 rejects exactly this pattern repo-wide as script-injection hardening, and the suite was failing. The version now reaches the shell through env as RELEASE_VERSION. - CFBundleVersion accepted prerelease suffixes. Apple restricts that field to period-separated integers, so every preview build would have shipped invalid metadata. The script now uses the numeric core for CFBundleVersion while CFBundleShortVersionString keeps the full human-facing string, and MACOS_BUILD_NUMBER (github.run_number in CI) appends a monotonic build component. Verified: 2.7.36-preview.1 produces 2.7.36, and 2.7.36.42 with a build number. - The output containment check compared $app_bundle against $output_root, both derived from the same variable, so it always passed. OUTPUT_DIR could point at /Applications and have an existing bundle recursively removed. The destination must now sit under the repository or a temp directory. Verified: /Applications is refused, /tmp is allowed. On Gatekeeper: the reviewer is right that the asset is ad-hoc signed and spctl rejects it. Developer ID signing plus notarization needs a paid Apple Developer account and this project has no certificate (verified: zero Developer ID identities, no Apple secrets in any workflow). Rather than pretend otherwise, build-macos-app.sh gained an optional MACOS_SIGN_IDENTITY that switches to hardened-runtime signing, package-macos-release.sh reports the spctl verdict and fails only when a real identity was claimed and still rejected, and release.yml wires the secret so adding a certificate becomes configuration rather than code. 040 documents what ships today and why the Phase 5 Gatekeeper section is mandatory. Also corrected the SIGPIPE note in 040: the reviewer reproduced the old pipeline exiting 0, so it is a race rather than a certainty — which is a better argument for fixing it, not a weaker one. * fix(release): honour Apple's actual version limits and drop the phantom secret Security re-review found my first version fix was still wrong, in a way I had not read carefully enough. - CFBundleShortVersionString must be exactly three integers, so a preview release was still writing "2.7.36-preview.1" into a field that does not accept it. It now gets the numeric core. - CFBundleVersion accepts ONE TO THREE integers and ignores a fourth. So "2.7.36." provided no additional identity at all — repeated builds of the same version compared as identical despite the run number. When CI supplies a run number it now becomes the CFBundleVersion outright: a single monotonically increasing integer is both valid and genuinely distinguishing. Verified: 2.7.36-preview.1 gives short 2.7.36 / build 2.7.36, and with a run number, build 1234. - The output containment check resolved logical paths, so a repository-local symlink pointing outside would pass the prefix test and then be deleted for real. Paths are now resolved with pwd -P, and a symlinked destination is refused outright. Verified: a symlink to a home directory is refused, while ordinary paths still build. - Removed MACOS_SIGN_IDENTITY from release.yml. The reviewer is right that an identity name alone cannot sign on a hosted runner — nothing imports the certificate and private key, so codesign fails with "no identity found". Advertising the secret implied a capability that does not exist. The build script keeps the hook for local signing and says so; real CI signing needs a protected P12 import, a temporary keychain, notarytool credentials, and stapling as one security-reviewed change. Also updated 040's executable snippets, which still showed the pre-review version handling and the direct inputs.version interpolation while later sections described the fixes — a source-of-truth document contradicting itself is worse than one that is merely incomplete. * docs(release): sync the Phase 4 plan with what actually shipped Closure blocker from the security review: 040 is the security-review artifact, and it still demonstrated the defects the last two rounds fixed. Copying its workflow example would have reintroduced the repository's prohibited injection pattern. Synchronised every stale snippet: - the tautological output guard is now the physical-path containment check - pwd gained -P where the implementation has it - the ad-hoc signing note no longer claims CI may re-sign, which the workflow deliberately does not support - the package job example carries MACOS_BUILD_NUMBER - the attach step passes RELEASE_VERSION through env instead of interpolating inputs.version into run: source - acceptance criterion 3a describes both Apple limits correctly rather than the invalid 2.7.36. form Also folded the Low finding: the script created the output directory before validating containment, so a refused path still left a directory behind. Validation now resolves the physical path by walking up to the nearest existing ancestor, and mkdir runs only after the check passes. Verified: a refused path creates nothing, symlinks outside the allowed roots are still refused, and ordinary builds are unaffected. * fix(release): normalise .. before the containment check, and cover it The containment fix was itself bypassable, which the reviewer demonstrated and I reproduced: resolve_physical walked up to the nearest existing ancestor and re-appended the missing tail verbatim, so /.ocx-nope/../../outside-probe resolved to itself, satisfied the prefix check, and mkdir -p then followed the .. components out of the repository. The build landed outside the permitted roots, where the destructive replace runs. The resolver now normalises the collected tail component by component, dropping "." and popping a level for "..". Verified: the same traversal is now refused, naming the RESOLVED path, and creates no directory. Added tests/macos-build-script.test.ts, which runs the real script: outside paths refused with nothing created, unresolved .. traversal refused, repository paths allowed, temp allowed. Writing that test surfaced its own trap worth recording: building the traversal with path.join() silently normalises the .. away, so the script never receives the bypass and the test passes against broken code. It is built by string concatenation instead. Sabotage-verified — reverting the normaliser fails exactly the traversal case and leaves the other three green. Also synced 040's snippet, which still showed the plain pwd -P form. * fix(release): normalise before resolving, and stop the test deleting fixed paths Second bypass in the same boundary, found by review and reproduced here. - resolve_physical resolved physically BEFORE normalising, so `..` could reveal a symlink that was then never followed: /.missing/../outward-link passed containment while pointing elsewhere. The order is now inverted — normalise lexically, then resolve the surviving path component by component so a symlink anywhere along it is followed. - Iteration is over a quoted array. `for part in $tail` word-split, so a literal glob such as `rel*` expanded against the filesystem. - Found while fixing it: `unset 'stack[-1]'` is a bad subscript in bash 3.2, which is what macOS ships. It failed silently, so `..` was never applied at all and the previous fix only appeared to work. Computes the index instead. Verified against every construction the reviewer named: a symlink reached through `..`, a direct outward symlink, a plain `..` traversal, and a literal glob. Each is refused naming the RESOLVED path, and none creates a directory. The regression test was itself unsafe: it recursively deleted fixed paths outside the repository, including /ocx-escaped-probe, which would have destroyed unrelated data if anything already lived there. A test for a safety boundary must not itself be destructive. Every fixture now lives in a mkdtemp sandbox or carries a pid-and-timestamp suffix, and the suite only removes what it created. Grew from 4 to 7 cases, adding both symlink forms and the glob. Sabotage-verified: restoring the bash 3.2 unset fails exactly the traversal and symlink cases and leaves the other five green. * fix(release): refuse symlinks that do not resolve to a directory Third bypass in this boundary, found by review and reproduced first. A symlink with a RELATIVE dangling target was joined onto the resolved prefix without normalising, so `link -> ../../outside` became `/../../outside`, satisfied the `/*` prefix check, and escaped during mkdir -p. Confirmed by building straight out of the repository before the fix. Rather than recursively resolve dangling targets with cycle detection, the script now refuses any symlink that does not resolve to an existing directory. OUTPUT_DIR has no legitimate reason to pass through one, and a refusal is easier to reason about than a clever resolver that has now been wrong three times. Two test-quality fixes from the same review: - The outside-path test derived its destination from process.env.HOME. Other suites replace HOME with a temp directory, and temp is a permitted root, so the script built there and the assertion failed during a full-suite run. It now uses a sibling of the repository, which no suite mutates. The full suite is green again: 4076 pass / 0 fail. - The glob test ran the child with cwd at the repository root while the glob sat under dist/, so the old unquoted loop had nothing to expand and the test would have passed against the broken implementation. It now runs in a sandbox that contains a matching entry and asserts the literal-star path was used rather than the decoy. Added a relative-escaping-symlink regression. Sabotage-verified: disabling the new symlink guard fails exactly the three symlink cases. 040's containment snippet now shows the real implementation, with all four bypasses recorded as the reason it looks the way it does, and criterion 3b describes the eight cases plus the two harness traps. * docs(release): make the Phase 4 containment snippet honest and complete Review passed the implementation and left one blocker: 040 is the security-review artifact, and its containment snippet still could not be trusted. - It called resolve_physical without defining it, so it was not executable. Now explicitly marked ABBREVIATED with the script named as authoritative. - It omitted the allowed_tmp branch. That is not cosmetic: macOS puts TMPDIR under /var/folders, so the documented version would have rejected the packaging script's own temporary build root while claiming to describe it. - Criterion 3b claimed coverage it did not describe. It now enumerates the eight cases and all three harness traps — the HOME mutation, the path.join normalisation, and the glob cwd — each of which made a test pass against broken code at some point. Also removed the `normalised` variable, which was computed and never read after the resolver was restructured. * docs(release): correct two counting errors in the Phase 4 criteria Review passed. Editorial only: 'Two harness details' introduced three bullets, and the eight-case list implied every case was a refusal when two are acceptance cases. * docs(macos): document the companion and the Gatekeeper first launch Phase 5 documentation. The guide ships in all five locales and is linked from the sidebar; docs-site builds 131 pages with all five present. The Gatekeeper section is the reason this guide is mandatory rather than nice-to-have. Users WILL see "cannot be opened because the developer cannot be verified", and the honest explanation is that Developer ID signing plus notarization needs a paid Apple Developer account the project does not have. So the guide says that plainly, gives the right-click-Open path and the xattr alternative, and points at building from source for anyone who wants neither. The rest documents what the app actually does rather than what a menu bar app usually does: the monochrome icon states and why colour is not used up there, the quota row showing the window under most pressure rather than the longest horizon, why the button says Stop proxy and not Restart, and the polling cadence — since a companion that hammers your own proxy every five seconds is a battery complaint waiting to happen. Also registered app/ in AGENTS.md and structure/00_overview.md. A new top-level directory that neither file mentions is invisible to the next agent, and the overview now states the boundary explicitly: the app is a client of the management API, so a change that needs a new endpoint is a change to the proxy first. * docs(macos): correct the API-key claim the app does not implement Review found the guide describing a flow that does not exist, in all five locales, and the same overclaim repeated in the #421 closing comment. Nothing calls Keychain.write. The app can READ a key under com.opencodex.menubar and retries once with it, but there is no entry UI, and "Add key…" only opens the dashboard — whose token lives in session storage and is unreachable from a native app. So a user with a non-loopback proxy stays on "Needs API key" no matter what the guide promised. All five locales now say that plainly: the key can be read from the Keychain, there is no way to enter one yet, a loopback proxy needs none, and native entry is planned. The uninstall section no longer claims a Keychain entry exists unless the user created it. Keychain.swift documents the same thing at the source. Also folded: - Bun was missing from the build prerequisites, so a machine with only Command Line Tools would hit "command not found" after satisfying the stated requirements. Added, with `bash scripts/build-macos-app.sh` as the no-Bun path. - README now names app/ as the source directory, which criterion 2 asked for literally and the previous wording only implied. - "the address it is listening on" was imprecise: the panel shows the loopback endpoint the app is using, which is not necessarily the proxy's configured bind. Reworded in all five locales. - Added the System Settings → Privacy & Security → Open Anyway fallback, since current macOS does not always offer an Open button in the first dialog. A corrective note on #421 follows separately — the credit there also needs fixing, and closing a PR with an inaccurate credit is worse than not crediting at all. * docs(macos): stop pointing users at a Keychain item they cannot create Review found the documented identifier does not match the code: the app queries service com.opencodex.menubar.apikey with account "default", while the guide named com.opencodex.menubar. All five locales repeated it, so the workaround I had just added would have left users exactly where they started. Rather than publish the exact identifier, the guides now say there is no supported way to provision the key by hand. That is the honest answer: the entry is a data-protection Keychain item, which Keychain Access does not create, so naming the service would send people down a path that does not work either. A loopback proxy — the default — needs no key, and native entry is planned. The uninstall sections no longer describe removing a Keychain item, since the app stores nothing there today. Also took the reviewer's suggestion on 050: criterion 1 said "Guide published", which implied a deployment this phase does not perform. It now says the source is added and the docs build verified, with publication following merge and Pages. * docs(app): align the Keychain comment with what the guides now say The source comment still told a maintainer that users create the Keychain item themselves — the exact workaround the guides just stopped publishing, because every query sets kSecUseDataProtectionKeychain and Keychain Access does not create data-protection items. Left as-is it would have reintroduced the invalid advice the next time someone read the source instead of the guide. * ci: declare macos-app in the aggregate gate after the dev rebase Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat: add usage timeline companion settings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(gui): companion section in Usage with live timeline preview Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(companion): timeline cache isolation, other-fold aggregation, ocx companion set/reset Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(app): settings-driven menu bar title, today metrics, timeline chart, widget snapshot export Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(widget): WidgetKit extension with small/medium/large families, packaged into OpenCodex.app Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: structure/docs/ci parity for the macOS companion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(widget): NSExtensionMain entry point, family-specific layouts, popover legend/captions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli): include companion in help banner Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ci): cover companion parity and GUI doctor findings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(gui): defer companion loading and translate French labels Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(gui): surface corrupt companion settings and correct the widget copy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(gui): reset fieldset chrome on the companion controls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(companion): default the menu bar headline to tokens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(companion): integer token abbreviation (K/M/B, no decimals) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(gui): companion install card driven by app presence Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(gui): scrollable model list with switches for the companion chart Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(app): Liquid Glass surfaces on macOS 26 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(companion): round integer token abbreviations and panel presence age Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs(companion): update integer token examples Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(companion): live presence refresh, tokens headline in the small widget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(companion): stable model ordering, tokens-first today row Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(companion): address macOS widget review findings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs(devlog): drop the duplicate _plan copy of the closed macOS unit The branch opened this unit under devlog/_plan/ before dev published the same unit as closed at devlog/_fin/260725_macos_menubar_app/ in 8ae52e4291. The rebase replayed the _plan addition on top of that publication, so the tree carried both copies: nine files, 2,605 lines, six byte-identical to their _fin counterparts. The three that differ are worse than redundant. 003_design_read.md, 010_phase1_core.md and 020_phase2_ui.md keep the decimal token text (12.4M, 36.5B) that this same pull request corrects to integers in the _fin copies, so the duplicate contradicted the corrected record two directories over. AGENTS.md defines _plan as units still open and _fin as closed work, and nothing in CI reads devlog/ - the file-size scanner excludes it - so no gate would have caught this. The _fin copies, including the 051_feature_summary.md this PR adds, remain the record. * ci(release): gate package-macos on dispatch validation and align the artifact pin Two defects in the release jobs this pull request adds, both found in review of the workflow surface. package-macos had no needs:, so a dispatch that validate-dispatch would reject still spun up a macOS runner and packaged an asset. Every other job in the file gates on that validation; this one now does too. The blast radius was bounded - contents: read, no secrets, and the script's own version guard - but running at all on a rejected dispatch is not the design. The upload step pinned actions/upload-artifact at v5.0.0 while ci.yml already pins v7.0.1, leaving the repository with two pins for one action and pairing a v5 upload against the v8 download in attach-macos. Both now use the SHA ci.yml already trusts, which is also the pairing actions/download-artifact v8 expects. --------- Co-authored-by: bitkyc08-arch Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: codex --- .github/workflows/ci.yml | 34 +- .github/workflows/release.yml | 69 +++ .gitignore | 2 + AGENTS.md | 5 + README.md | 15 + app/.gitignore | 4 + app/Info.plist | 42 ++ app/Package.swift | 55 +++ app/Sources/IconProbe/main.swift | 32 ++ app/Sources/MenuBarApp/main.swift | 11 + .../MenuBarCore/ActionCoordinator.swift | 123 +++++ .../MenuBarCore/CompanionSettings.swift | 118 +++++ app/Sources/MenuBarCore/Discovery.swift | 80 ++++ app/Sources/MenuBarCore/Formatting.swift | 106 +++++ app/Sources/MenuBarCore/Keychain.swift | 76 ++++ app/Sources/MenuBarCore/MenuBarTitle.swift | 38 ++ .../MenuBarCore/PollingCoordinator.swift | 268 +++++++++++ app/Sources/MenuBarCore/ProxyClient.swift | 332 ++++++++++++++ app/Sources/MenuBarCore/ProxyModels.swift | 285 ++++++++++++ app/Sources/MenuBarCore/ProxySnapshot.swift | 201 +++++++++ app/Sources/MenuBarCore/UsageTimeline.swift | 39 ++ app/Sources/MenuBarCore/WidgetSnapshot.swift | 190 ++++++++ .../MenuBarCoreTests/ActionSuite.swift | 356 +++++++++++++++ .../CompanionSettingsSuite.swift | 27 ++ .../MenuBarCoreTests/DiscoverySuite.swift | 82 ++++ .../MenuBarCoreTests/FormattingSuite.swift | 77 ++++ app/Sources/MenuBarCoreTests/Harness.swift | 105 +++++ .../MenuBarCoreTests/MenuBarTitleSuite.swift | 36 ++ .../MenuBarCoreTests/ModelDecodingSuite.swift | 258 +++++++++++ .../MenuBarCoreTests/PollingSuite.swift | 408 +++++++++++++++++ .../MenuBarCoreTests/SnapshotStateSuite.swift | 106 +++++ .../TimelineDecodingSuite.swift | 15 + .../MenuBarCoreTests/TransportSuite.swift | 362 +++++++++++++++ .../WidgetSnapshotSuite.swift | 37 ++ app/Sources/MenuBarCoreTests/main.swift | 20 + app/Sources/MenuBarUI/AppDelegate.swift | 254 +++++++++++ app/Sources/MenuBarUI/CompanionViews.swift | 78 ++++ app/Sources/MenuBarUI/PopoverPanel.swift | 170 +++++++ .../MenuBarUI/PopoverViewController.swift | 384 ++++++++++++++++ app/Sources/MenuBarUI/ProviderListView.swift | 255 +++++++++++ app/Sources/MenuBarUI/StatusIcon.swift | 73 +++ app/Sources/MenuBarUI/Theme.swift | 103 +++++ app/Sources/MenuBarUI/TimelineChartView.swift | 135 ++++++ app/Sources/MenuBarUI/Views.swift | 264 +++++++++++ app/Sources/MenuBarUITests/Harness.swift | 105 +++++ app/Sources/MenuBarUITests/main.swift | 165 +++++++ app/Sources/OpenCodexWidget/Provider.swift | 49 ++ .../OpenCodexWidget/SnapshotReader.swift | 31 ++ app/Sources/OpenCodexWidget/Views.swift | 323 ++++++++++++++ app/Sources/OpenCodexWidget/main.swift | 2 + app/Sources/UIProbe/main.swift | 165 +++++++ app/Widget-Info.plist | 20 + app/Widget.entitlements | 8 + .../003_design_read.md | 4 +- .../010_phase1_core.md | 2 +- .../260725_macos_menubar_app/020_phase2_ui.md | 2 +- .../051_feature_summary.md | 12 + docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/macos-menu-bar.md | 161 +++++++ .../content/docs/ja/guides/macos-menu-bar.md | 157 +++++++ .../content/docs/ko/guides/macos-menu-bar.md | 153 +++++++ .../content/docs/ru/guides/macos-menu-bar.md | 159 +++++++ .../docs/zh-cn/guides/macos-menu-bar.md | 139 ++++++ gui/src/i18n/de.ts | 63 +++ gui/src/i18n/en.ts | 63 +++ gui/src/i18n/fr.ts | 63 +++ gui/src/i18n/ja.ts | 63 +++ gui/src/i18n/ko.ts | 63 +++ gui/src/i18n/ru.ts | 63 +++ gui/src/i18n/tr.ts | 63 +++ gui/src/i18n/vi.ts | 63 +++ gui/src/i18n/zh-TW.ts | 63 +++ gui/src/i18n/zh.ts | 63 +++ gui/src/pages/Usage.tsx | 19 + gui/src/pages/usage-companion-chart.tsx | 119 +++++ gui/src/pages/usage-companion-panel.tsx | 422 ++++++++++++++++++ gui/src/pages/usage-companion-utils.ts | 214 +++++++++ gui/src/styles-usage-workspace.css | 84 ++++ gui/tests/usage-companion-utils.test.ts | 77 ++++ package.json | 3 + readme/README.fr.md | 13 + readme/README.ja.md | 12 + readme/README.ko.md | 12 + readme/README.ru.md | 12 + readme/README.tr.md | 12 + readme/README.zh-CN.md | 11 + readme/README.zh-TW.md | 11 + readme/i18n-manifest.json | 14 +- scripts/build-macos-app.sh | 268 +++++++++++ scripts/package-macos-release.sh | 113 +++++ scripts/test-layout/layout.json | 4 + .../ocx/references/01_management_surface.md | 23 +- src/cli/capabilities.ts | 16 + src/cli/companion.ts | 56 +++ src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/registry.ts | 10 + src/companion/settings.ts | 131 ++++++ src/server/management-api.ts | 6 +- src/server/management/companion-routes.ts | 53 +++ src/server/management/route-registry.ts | 5 + .../management/usage-timeline-routes.ts | 44 ++ src/usage/summary.ts | 4 +- src/usage/timeline.ts | 219 +++++++++ structure/INDEX.md | 3 + structure/gui-and-management-api.md | 6 +- structure/manifest.json | 4 + structure/overview.md | 6 + tests/ci-workflows/ci-structure-gate.test.ts | 11 +- tests/ci-workflows/ci-workflows.test.ts | 1 + tests/cli/cli-companion.test.ts | 28 ++ tests/cli/cli-headless-parity.test.ts | 1 + tests/fixtures/test-layout-expected.json | 4 + tests/gui/macos-build-script.test.ts | 201 +++++++++ tests/server/companion-settings.test.ts | 86 ++++ tests/usage/usage-timeline.test.ts | 117 +++++ 116 files changed, 10457 insertions(+), 21 deletions(-) create mode 100644 app/.gitignore create mode 100644 app/Info.plist create mode 100644 app/Package.swift create mode 100644 app/Sources/IconProbe/main.swift create mode 100644 app/Sources/MenuBarApp/main.swift create mode 100644 app/Sources/MenuBarCore/ActionCoordinator.swift create mode 100644 app/Sources/MenuBarCore/CompanionSettings.swift create mode 100644 app/Sources/MenuBarCore/Discovery.swift create mode 100644 app/Sources/MenuBarCore/Formatting.swift create mode 100644 app/Sources/MenuBarCore/Keychain.swift create mode 100644 app/Sources/MenuBarCore/MenuBarTitle.swift create mode 100644 app/Sources/MenuBarCore/PollingCoordinator.swift create mode 100644 app/Sources/MenuBarCore/ProxyClient.swift create mode 100644 app/Sources/MenuBarCore/ProxyModels.swift create mode 100644 app/Sources/MenuBarCore/ProxySnapshot.swift create mode 100644 app/Sources/MenuBarCore/UsageTimeline.swift create mode 100644 app/Sources/MenuBarCore/WidgetSnapshot.swift create mode 100644 app/Sources/MenuBarCoreTests/ActionSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/DiscoverySuite.swift create mode 100644 app/Sources/MenuBarCoreTests/FormattingSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/Harness.swift create mode 100644 app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/PollingSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/TransportSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/main.swift create mode 100644 app/Sources/MenuBarUI/AppDelegate.swift create mode 100644 app/Sources/MenuBarUI/CompanionViews.swift create mode 100644 app/Sources/MenuBarUI/PopoverPanel.swift create mode 100644 app/Sources/MenuBarUI/PopoverViewController.swift create mode 100644 app/Sources/MenuBarUI/ProviderListView.swift create mode 100644 app/Sources/MenuBarUI/StatusIcon.swift create mode 100644 app/Sources/MenuBarUI/Theme.swift create mode 100644 app/Sources/MenuBarUI/TimelineChartView.swift create mode 100644 app/Sources/MenuBarUI/Views.swift create mode 100644 app/Sources/MenuBarUITests/Harness.swift create mode 100644 app/Sources/MenuBarUITests/main.swift create mode 100644 app/Sources/OpenCodexWidget/Provider.swift create mode 100644 app/Sources/OpenCodexWidget/SnapshotReader.swift create mode 100644 app/Sources/OpenCodexWidget/Views.swift create mode 100644 app/Sources/OpenCodexWidget/main.swift create mode 100644 app/Sources/UIProbe/main.swift create mode 100644 app/Widget-Info.plist create mode 100644 app/Widget.entitlements create mode 100644 devlog/_fin/260725_macos_menubar_app/051_feature_summary.md create mode 100644 docs-site/src/content/docs/guides/macos-menu-bar.md create mode 100644 docs-site/src/content/docs/ja/guides/macos-menu-bar.md create mode 100644 docs-site/src/content/docs/ko/guides/macos-menu-bar.md create mode 100644 docs-site/src/content/docs/ru/guides/macos-menu-bar.md create mode 100644 docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md create mode 100644 gui/src/pages/usage-companion-chart.tsx create mode 100644 gui/src/pages/usage-companion-panel.tsx create mode 100644 gui/src/pages/usage-companion-utils.ts create mode 100644 gui/tests/usage-companion-utils.test.ts create mode 100755 scripts/build-macos-app.sh create mode 100755 scripts/package-macos-release.sh create mode 100644 src/cli/companion.ts create mode 100644 src/companion/settings.ts create mode 100644 src/server/management/companion-routes.ts create mode 100644 src/server/management/usage-timeline-routes.ts create mode 100644 src/usage/timeline.ts create mode 100644 tests/cli/cli-companion.test.ts create mode 100644 tests/gui/macos-build-script.test.ts create mode 100644 tests/server/companion-settings.test.ts create mode 100644 tests/usage/usage-timeline.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 003415f3af5..e5eacaa1e9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ on: - "bin/**" - "tests/**" - "scripts/**" + - "app/**" - "gui/**" - "assets/**" - ".gitattributes" @@ -213,6 +214,7 @@ jobs: - 'bin/**' - 'tests/**' - 'scripts/**' + - 'app/**' - 'gui/**' - 'assets/**' - '.gitattributes' @@ -1147,6 +1149,32 @@ jobs: # `if: always()` is load-bearing. Without it, a failed or skipped dependency # skips this job too — and GitHub reports a skipped job as success, so the gate # would go green precisely when something went wrong. + macos-app: + name: macos app + needs: [changes, gates] + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: macos-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test macOS menu bar app + run: bun run test:macos + + - name: Build macOS menu bar app + run: bun run build:macos + ci: name: ci if: always() @@ -1154,7 +1182,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, macos-app] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1220,12 +1248,12 @@ jobs: GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" - GATED_JOBS="$GATED_JOBS structure-gate" + GATED_JOBS="$GATED_JOBS structure-gate macos-app" expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke) + test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|macos-app) echo "$scoped" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b565b68003..2c3c2002583 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,75 @@ jobs: process.exit(1); } NODE + package-macos: + needs: validate-dispatch + runs-on: macos-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + archive_name: ${{ steps.package.outputs.archive_name }} + checksum_name: ${{ steps.package.outputs.checksum_name }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Package the macOS companion + id: package + env: + RELEASE_VERSION: ${{ inputs.version }} + UNIVERSAL: "1" + # A monotonic numeric CFBundleVersion. Preview versions carry a suffix that + # Apple does not accept in that field, so the script uses the numeric core + # plus this run number. + MACOS_BUILD_NUMBER: ${{ github.run_number }} + # NOTE: intentionally no MACOS_SIGN_IDENTITY here. The build script honours + # it, but an identity NAME alone cannot sign on a hosted runner — the + # certificate and private key are never imported into a keychain, so codesign + # fails with "no identity found". Real Developer ID signing needs a protected + # P12 import, a temporary keychain, notarytool credentials, and stapling, all + # as one security-reviewed change. Until then the asset is ad-hoc signed and + # the docs carry the Gatekeeper first-launch path. + run: bash scripts/package-macos-release.sh + + - name: Upload the release asset + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-release + path: dist/release/ + if-no-files-found: error + retention-days: 7 + + attach-macos: + runs-on: ubuntu-latest + needs: [publish, package-macos] + if: ${{ inputs.dry-run != true }} + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download the packaged asset + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: macos-release + path: dist/release + + - name: Verify the checksum before uploading + run: | + cd dist/release + shasum -a 256 -c ./*.sha256 + + - name: Attach to the release + env: + GH_TOKEN: ${{ github.token }} + # Workflow inputs reach shell code through env, never by interpolation into + # run: source. tests/ci-workflows.test.ts enforces this repo-wide. + RELEASE_VERSION: ${{ inputs.version }} + run: | + gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber + publish: needs: validate-dispatch runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 1973231f05b..e6dd44882e5 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,5 @@ go/ # Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. native/**/target/ +dist/macos/ +dist/release/ diff --git a/AGENTS.md b/AGENTS.md index 1f621b99b41..469912b5e39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,11 @@ Bun-native TypeScript with no separate server compile step. seeds in `layout.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. +- `app/` — native macOS menu bar companion (Swift + AppKit, no third-party + dependencies). `MenuBarCore` is the testable transport/model layer, + `MenuBarUI` the AppKit views, `MenuBarApp` the entry point. Its tests are + executables, not XCTest bundles — Command Line Tools ships neither a usable + XCTest module nor the swift-testing runtime. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. - `go/` — retired Go native-runtime experiment; kept only where the TypeScript runtime still references it. New work does not go here. diff --git a/README.md b/README.md index d29cd2f43af..88a6438b3dc 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,21 @@ Use `ocx service` to run it in the background. Open **http://localhost:10100** and configure everything in the web dashboard — add providers (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. + +### macOS menu bar app + +A native companion for proxy status, usage, and provider quotas without opening the +dashboard. The source lives in [`app/`](./app) (Swift + AppKit, no third-party +dependencies). Download it from the +[releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with +`bun run build:macos`. + +The first launch needs a right-click → Open, because the app is ad-hoc signed rather +than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +for the full explanation. + +The app also includes a macOS 14+ widget for proxy status, today's usage, and quotas. + It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000000..4629e801bfa --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +*.xcodeproj +DerivedData/ diff --git a/app/Info.plist b/app/Info.plist new file mode 100644 index 00000000000..3d52873063f --- /dev/null +++ b/app/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + OpenCodexMenuBar + CFBundleIdentifier + com.opencodex.menubar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OpenCodex + CFBundleDisplayName + OpenCodex + CFBundlePackageType + APPL + CFBundleIconFile + OpenCodex + CFBundleShortVersionString + 0.0.0 + CFBundleVersion + 0.0.0 + LSUIElement + + LSMinimumSystemVersion + 13.0 + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSHumanReadableCopyright + MIT — opencodex contributors + + diff --git a/app/Package.swift b/app/Package.swift new file mode 100644 index 00000000000..9e5f1372751 --- /dev/null +++ b/app/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "OpenCodexMenuBar", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), + .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), + .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), + .executable(name: "MenuBarUITests", targets: ["MenuBarUITests"]), + .executable(name: "UIProbe", targets: ["UIProbe"]), + .executable(name: "IconProbe", targets: ["IconProbe"]), + ], + targets: [ + .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), + // AppKit views live in a library so both the app and the visual-QA probe can + // build the same surface. An executable target cannot be imported. + .target(name: "MenuBarUI", dependencies: ["MenuBarCore"], path: "Sources/MenuBarUI"), + .executableTarget( + name: "MenuBarApp", + dependencies: ["MenuBarCore", "MenuBarUI"], + path: "Sources/MenuBarApp" + ), + .executableTarget( + name: "OpenCodexWidget", + dependencies: ["MenuBarCore"], + path: "Sources/OpenCodexWidget", + linkerSettings: [ + // Widget extensions must enter through NSExtensionMain or chronod tears down + // the process before the WidgetBundle connects. + .linkedFramework("Foundation"), + .unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), + ] + ), + // An executable rather than a .testTarget: Xcode Command Line Tools ships + // neither a usable XCTest module nor the swift-testing runtime, so a test bundle + // cannot run without a full Xcode install. See Sources/MenuBarCoreTests/Harness.swift. + .executableTarget( + name: "MenuBarCoreTests", + dependencies: ["MenuBarCore"], + path: "Sources/MenuBarCoreTests" + ), + // UI-layer tests need AppKit and an NSApplication, so they are a separate + // executable from the dependency-free core suite. + .executableTarget( + name: "MenuBarUITests", + dependencies: ["MenuBarCore", "MenuBarUI"], + path: "Sources/MenuBarUITests" + ), + .executableTarget(name: "UIProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/UIProbe"), + .executableTarget(name: "IconProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/IconProbe"), + ], + swiftLanguageVersions: [.v5] +) diff --git a/app/Sources/IconProbe/main.swift b/app/Sources/IconProbe/main.swift new file mode 100644 index 00000000000..f9a359114d5 --- /dev/null +++ b/app/Sources/IconProbe/main.swift @@ -0,0 +1,32 @@ +// Renders every menu bar glyph state to one sheet so the state signal can be verified +// visually. The notch previously did not render at all, which made protected and +// at-risk indistinguishable. +import AppKit +import MenuBarCore +import MenuBarUI + +let states: [(String, ProxyState)] = [ + ("protected", .running(StartupHealth(status: "protected"))), + ("at-risk", .running(StartupHealth(status: "at-risk"))), + ("loading", .loading), + ("stopped", .unreachable), +] + +let scale: CGFloat = 6 +let cell = NSSize(width: 17 * scale, height: 17 * scale) +let sheet = NSImage(size: NSSize(width: cell.width * CGFloat(states.count), height: cell.height)) +sheet.lockFocus() +NSColor.white.setFill() +NSRect(origin: .zero, size: sheet.size).fill() +for (i, entry) in states.enumerated() { + let img = StatusIcon.image(for: entry.1) + let rect = NSRect(x: CGFloat(i) * cell.width, y: 0, width: cell.width, height: cell.height) + NSGraphicsContext.current?.imageInterpolation = .none + img.draw(in: rect.insetBy(dx: 8, dy: 8)) +} +sheet.unlockFocus() +if let tiff = sheet.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: "/tmp/glyphs.png")) +} +print("wrote /tmp/glyphs.png:", states.map(\.0).joined(separator: ", ")) diff --git a/app/Sources/MenuBarApp/main.swift b/app/Sources/MenuBarApp/main.swift new file mode 100644 index 00000000000..710ac8c51b9 --- /dev/null +++ b/app/Sources/MenuBarApp/main.swift @@ -0,0 +1,11 @@ +import AppKit +import MenuBarUI + +let app = NSApplication.shared +// .accessory keeps it out of the Dock; LSUIElement in Info.plist does the same for the +// packaged bundle, and this covers `swift run` during development. +app.setActivationPolicy(.accessory) + +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/app/Sources/MenuBarCore/ActionCoordinator.swift b/app/Sources/MenuBarCore/ActionCoordinator.swift new file mode 100644 index 00000000000..5666ad48c92 --- /dev/null +++ b/app/Sources/MenuBarCore/ActionCoordinator.swift @@ -0,0 +1,123 @@ +import Foundation + +/// The result of a write action, in terms the UI can render directly. +public enum ActionOutcome: Equatable, Sendable { + case succeeded + /// The stop was confirmed, but nothing will restart the proxy — the user has to. + case requiresManualStart(String) + /// The proxy stopped, but it could not restore native Codex on the way out, so the + /// user's Codex config still points at a port that is now closed. + case stoppedWithRestoreFailure(String) + /// A human sentence. Never a response body: bodies can echo configuration. + case failed(String) +} + +/// Executes write actions and reports what actually happened. +/// +/// Split from the UI because the interesting behaviour is timing, not presentation: +/// `/api/stop` answers before it drains, so "the request returned 200" and "the proxy +/// stopped" are different facts and only the second one is worth telling the user. +public actor ActionCoordinator { + /// How long to wait for the port to stop answering before giving up. + public static let stopTimeout: TimeInterval = 10 + public static let pollInterval: TimeInterval = 0.5 + + private let client: ProxyClient + /// One in-flight write per provider. Both this actor and `ProxyClient` are reentrant + /// across network awaits, so two rapid toggles could otherwise reach the server out + /// of order and leave it opposite to the user's last click. + private var inFlight: Set = [] + private let sleeper: @Sendable (TimeInterval) async -> Void + /// Injected so tests can advance time without waiting for it. A no-op sleeper alone + /// is not enough: the loop is bounded by a deadline, so the clock has to move too. + private let now: @Sendable () -> Date + + public init( + client: ProxyClient, + sleeper: @escaping @Sendable (TimeInterval) async -> Void = { seconds in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.client = client + self.sleeper = sleeper + self.now = now + } + + /// Stops the proxy and waits until it is actually gone. + /// + /// `/api/stop` calls `stopServiceIfInstalled()` and returns before draining, so a + /// 200 means "accepted", not "stopped". Reporting success on the response alone + /// would make the UI claim a state the system has not reached yet. + public func stop(startCommand: String) async -> ActionOutcome { + let restored: Bool + do { + restored = try await client.stop() + } catch let error as ProxyError { + return .failed(error.userMessage) + } catch { + return .failed("Could not reach the proxy to stop it.") + } + + let deadline = now().addingTimeInterval(Self.stopTimeout) + var sawIndeterminate = false + while now() < deadline { + await sleeper(Self.pollInterval) + // Cap the probe to whatever time is left, so the last one cannot overrun the + // deadline by its own timeout. + let remaining = deadline.timeIntervalSince(now()) + guard remaining > 0 else { break } + switch await client.liveness(timeout: min(1.5, remaining)) { + case .refused: + // The only proof the proxy is actually gone. + return restored + ? .requiresManualStart(startCommand) + : .stoppedWithRestoreFailure(startCommand) + case .reachable: + sawIndeterminate = false + case .indeterminate: + // A timeout proves nothing; keep polling rather than declaring victory. + sawIndeterminate = true + } + } + + return .failed( + sawIndeterminate + ? "The proxy accepted the stop, but its state could not be confirmed. Check with `ocx status`." + : "The proxy accepted the stop but was still responding after \(Int(Self.stopTimeout)) seconds." + ) + } + + /// Enables or disables a provider. + /// + /// The default provider is rejected before any request is sent: the proxy answers + /// 400 for that case, and firing a request that cannot succeed is worse than not + /// offering it. + public func setProvider( + _ name: String, + disabled: Bool, + defaultProvider: String? + ) async -> ActionOutcome { + if disabled, name == defaultProvider { + return .failed("\(name) is the default provider. Choose another default in the dashboard first.") + } + guard !inFlight.contains(name) else { + return .failed("A change to \(name) is still in progress.") + } + inFlight.insert(name) + defer { inFlight.remove(name) } + + do { + try await client.setProviderDisabled(name, disabled: disabled) + return .succeeded + } catch ProxyError.http(400) { + // The proxy validates more than we can predict; surface its refusal without + // quoting its body. + return .failed("The proxy refused that change. Adjust it in the dashboard.") + } catch let error as ProxyError { + return .failed(error.userMessage) + } catch { + return .failed("That change could not be applied.") + } + } +} diff --git a/app/Sources/MenuBarCore/CompanionSettings.swift b/app/Sources/MenuBarCore/CompanionSettings.swift new file mode 100644 index 00000000000..2e3b0333b1a --- /dev/null +++ b/app/Sources/MenuBarCore/CompanionSettings.swift @@ -0,0 +1,118 @@ +import Foundation + +public struct CompanionSettings: Decodable, Equatable, Sendable { + public enum MenuBarMetric: String, Sendable { + case requests, tokens, cost, quota, none + } + + public enum ChartStyle: String, Sendable { + case line, stackedBar + } + + public enum TokenMetric: String, Sendable { + case total, input, output, cached + } + + public enum Aggregation: String, Sendable { + case sum, average, max + } + + public enum ChartGrouping: String, Sendable { + case model, modelAccount + } + + public let menuBarMetric: MenuBarMetric + public let menuBarTemplate: String? + public let showToday: Bool + public let showChart: Bool + public let showModels: Bool + public let showCost: Bool + public let showAccounts: Bool + public let chartHours: Int + public let bucketMinutes: Int + public let chartStyle: ChartStyle + public let tokenMetric: TokenMetric + public let aggregation: Aggregation + public let chartGrouping: ChartGrouping + public let models: [String]? + public let hiddenProviders: [String] + + public static let defaults = CompanionSettings( + menuBarMetric: .tokens, menuBarTemplate: nil, + showToday: true, showChart: true, showModels: true, showCost: true, showAccounts: true, + chartHours: 24, bucketMinutes: 60, chartStyle: .line, tokenMetric: .total, + aggregation: .sum, chartGrouping: .model, models: nil, hiddenProviders: [] + ) + + public init( + menuBarMetric: MenuBarMetric = .tokens, + menuBarTemplate: String? = nil, + showToday: Bool = true, + showChart: Bool = true, + showModels: Bool = true, + showCost: Bool = true, + showAccounts: Bool = true, + chartHours: Int = 24, + bucketMinutes: Int = 60, + chartStyle: ChartStyle = .line, + tokenMetric: TokenMetric = .total, + aggregation: Aggregation = .sum, + chartGrouping: ChartGrouping = .model, + models: [String]? = nil, + hiddenProviders: [String] = [] + ) { + self.menuBarMetric = menuBarMetric + self.menuBarTemplate = menuBarTemplate + self.showToday = showToday + self.showChart = showChart + self.showModels = showModels + self.showCost = showCost + self.showAccounts = showAccounts + self.chartHours = chartHours + self.bucketMinutes = bucketMinutes + self.chartStyle = chartStyle + self.tokenMetric = tokenMetric + self.aggregation = aggregation + self.chartGrouping = chartGrouping + self.models = models + self.hiddenProviders = hiddenProviders + } + + private enum CodingKeys: String, CodingKey { + case menuBarMetric, menuBarTemplate, showToday, showChart, showModels, showCost, showAccounts + case chartHours, bucketMinutes, chartStyle, tokenMetric, aggregation, chartGrouping, models, hiddenProviders + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + menuBarMetric: Self.enumValue(MenuBarMetric.self, try c.decodeIfPresent(String.self, forKey: .menuBarMetric), default: .tokens), + menuBarTemplate: try c.decodeIfPresent(String.self, forKey: .menuBarTemplate), + showToday: try c.decodeIfPresent(Bool.self, forKey: .showToday) ?? true, + showChart: try c.decodeIfPresent(Bool.self, forKey: .showChart) ?? true, + showModels: try c.decodeIfPresent(Bool.self, forKey: .showModels) ?? true, + showCost: try c.decodeIfPresent(Bool.self, forKey: .showCost) ?? true, + showAccounts: try c.decodeIfPresent(Bool.self, forKey: .showAccounts) ?? true, + chartHours: try c.decodeIfPresent(Int.self, forKey: .chartHours) ?? 24, + bucketMinutes: try c.decodeIfPresent(Int.self, forKey: .bucketMinutes) ?? 60, + chartStyle: Self.enumValue(ChartStyle.self, try c.decodeIfPresent(String.self, forKey: .chartStyle), default: .line), + tokenMetric: Self.enumValue(TokenMetric.self, try c.decodeIfPresent(String.self, forKey: .tokenMetric), default: .total), + aggregation: Self.enumValue(Aggregation.self, try c.decodeIfPresent(String.self, forKey: .aggregation), default: .sum), + chartGrouping: Self.enumValue(ChartGrouping.self, try c.decodeIfPresent(String.self, forKey: .chartGrouping), default: .model), + models: try c.decodeIfPresent([String].self, forKey: .models), + hiddenProviders: try c.decodeIfPresent([String].self, forKey: .hiddenProviders) ?? [] + ) + } + + private static func enumValue( + _ type: T.Type, _ raw: String?, default value: T + ) -> T where T.RawValue == String { + raw.flatMap(T.init(rawValue:)) ?? value + } +} + +public struct CompanionSettingsResponse: Decodable, Equatable, Sendable { + public let settings: CompanionSettings + public let updatedAt: Double? + public let corrupt: Bool? +} diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift new file mode 100644 index 00000000000..412a5ff5c0b --- /dev/null +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -0,0 +1,80 @@ +import Foundation + +/// A loopback endpoint for the local OpenCodex proxy. +/// +/// The host is deliberately fixed to loopback and never read from disk: the port record +/// is a convenience, not a redirection mechanism. +public struct ProxyEndpoint: Equatable, Sendable { + public static let loopbackHost = "127.0.0.1" + public static let validPorts = 1...65535 + + public let host: String + public let port: Int + private let resolvedURL: URL + + /// Fails rather than traps on an out-of-range port. `baseURL` is built once here, so + /// no accessor can crash later on a value that was never a valid URL. + public init?(port: Int) { + guard Self.validPorts.contains(port), + let url = URL(string: "http://\(Self.loopbackHost):\(port)") + else { return nil } + self.host = Self.loopbackHost + self.port = port + self.resolvedURL = url + } + + /// The default endpoint, which is known-valid by construction. + public static let `default` = ProxyEndpoint(port: ProxyDiscovery.defaultPort)! + + public var baseURL: URL { resolvedURL } + + public var display: String { "\(host):\(port)" } +} + +struct RuntimePortRecord: Decodable { + let pid: Int? + let port: Int +} + +/// Resolves where the proxy is listening, mirroring `resolveRuntimePortPath()` in +/// `src/config.ts`. +public enum ProxyDiscovery { + public static let defaultPort = 10100 + public static var validPorts: ClosedRange { ProxyEndpoint.validPorts } + + /// `OPENCODEX_HOME` when set and non-empty, else `~/.opencodex`. + public static func configDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespaces), + !override.isEmpty { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) + } + return home.appendingPathComponent(".opencodex", isDirectory: true) + } + + /// Reads `runtime-port.json`, falling back to the default port on any problem. + /// + /// Every failure mode — missing file, malformed JSON, out-of-range port — resolves to + /// the default rather than throwing. A menu bar app that refuses to start because a + /// cache file is unreadable would be worse than one that probes the usual port. + public static func resolve(configDirectory directory: URL) -> ProxyEndpoint { + let file = directory.appendingPathComponent("runtime-port.json") + guard + let data = try? Data(contentsOf: file), + let record = try? JSONDecoder().decode(RuntimePortRecord.self, from: data), + let endpoint = ProxyEndpoint(port: record.port) + else { + return .default + } + return endpoint + } + + public static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> ProxyEndpoint { + resolve(configDirectory: configDirectory(environment: environment, home: home)) + } +} diff --git a/app/Sources/MenuBarCore/Formatting.swift b/app/Sources/MenuBarCore/Formatting.swift new file mode 100644 index 00000000000..4008f53f34f --- /dev/null +++ b/app/Sources/MenuBarCore/Formatting.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Number and date presentation for a 340pt popover. +/// +/// Live data reaches `requests: 232507`, `totalTokens: 36536664705`, and +/// `estimatedCostUsd: 34018.25`. Rendering those verbatim destroys the layout, so every +/// value is abbreviated and every unknown is an em dash — never a plausible-looking zero. +public enum Format { + public static let unknown = "—" + + private static let grouping: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.groupingSeparator = "," + f.maximumFractionDigits = 0 + return f + }() + + /// Counts: grouped below 10 000, then SI-suffixed with 3 significant figures. + public static func count(_ value: Int?) -> String { + guard let value else { return unknown } + if value < 10_000 { + return grouping.string(from: NSNumber(value: value)) ?? String(value) + } + return abbreviate(Double(value)) + } + + /// Tokens are always suffixed — they are never small enough to be worth grouping. + public static func tokens(_ value: Int?) -> String { + guard let value else { return unknown } + if value < 1_000 { return String(value) } + return abbreviate(Double(value), integer: true) + } + + public static func cost(_ value: Double?) -> String { + guard let value else { return unknown } + if value < 1_000 { + return String(format: "$%.2f", value) + } + return "$" + abbreviate(value) + } + + public static func percent(_ value: Double?) -> String { + guard let value else { return unknown } + return "\(Int(value.rounded()))%" + } + + /// "resets in 3d 4h" / "resets in 12m". Past dates read as "expired". + public static func resetsIn(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return unknown } + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "expired" } + + let totalMinutes = Int(interval / 60) + let days = totalMinutes / 1440 + let hours = (totalMinutes % 1440) / 60 + let minutes = totalMinutes % 60 + + if days > 0 { return hours > 0 ? "\(days)d \(hours)h" : "\(days)d" } + if hours > 0 { return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" } + return "\(max(minutes, 1))m" + } + + /// "2m ago" for staleness labels on the degraded state. + public static func age(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return unknown } + let seconds = Int(now.timeIntervalSince(date)) + if seconds < 60 { return "just now" } + if seconds < 3600 { return "\(seconds / 60)m ago" } + if seconds < 86_400 { return "\(seconds / 3600)h ago" } + return "\(seconds / 86_400)d ago" + } + + private static func abbreviate(_ value: Double, integer: Bool = false) -> String { + let units: [(threshold: Double, suffix: String)] = [ + (1_000_000_000_000, "T"), + (1_000_000_000, "B"), + (1_000_000, "M"), + (1_000, "K"), + ] + // Ascending, so promotion is a simple step to the next entry. + let ascending = units.reversed().map { $0 } + + for (index, unit) in ascending.enumerated() where value < (unit.threshold * 1000) { + let rendered = render(value / unit.threshold, suffix: unit.suffix, integer: integer) + // Rounding can push a value across its own boundary: 999_999 scales to + // 999.999K, which would render "1000K" instead of promoting to "1.00M". + guard rendered.hasPrefix("1000"), index + 1 < ascending.count else { return rendered } + let larger = ascending[index + 1] + return render(value / larger.threshold, suffix: larger.suffix, integer: integer) + } + + // Beyond the largest unit, stay in that unit rather than inventing a suffix. + if let largest = ascending.last, value >= largest.threshold { + return render(value / largest.threshold, suffix: largest.suffix, integer: integer) + } + return String(format: "%.0f", value) + } + + /// Render an abbreviated value with either integer or 3-significant-figure precision. + private static func render(_ scaled: Double, suffix: String, integer: Bool = false) -> String { + if integer { return String(format: "%.0f%@", scaled, suffix) } + let decimals = scaled >= 100 ? 0 : (scaled >= 10 ? 1 : 2) + return String(format: "%.\(decimals)f%@", scaled, suffix) + } +} diff --git a/app/Sources/MenuBarCore/Keychain.swift b/app/Sources/MenuBarCore/Keychain.swift new file mode 100644 index 00000000000..2d67ecb5de3 --- /dev/null +++ b/app/Sources/MenuBarCore/Keychain.swift @@ -0,0 +1,76 @@ +import Foundation +import Security + +/// Generic-password storage for the optional management API key. +/// +/// The key is read lazily — only after a 401 — and is never written to UserDefaults, +/// never logged, and never included in an error surfaced to the UI. +/// +/// **Read-only in practice today, and there is no way to provision the key.** Nothing in +/// the app calls `write`, because there is no key-entry UI yet — and a user cannot fill +/// the gap by hand either: every query sets `kSecUseDataProtectionKeychain`, and +/// Keychain Access does not create data-protection items. So a non-loopback bind is +/// genuinely unsupported rather than merely inconvenient, and the docs say exactly that. +/// +/// `write`/`delete` exist for the native entry flow that is planned. Do not document a +/// manual workaround on top of them: an earlier revision of the guide did, naming a +/// service that was both wrong and unreachable. +/// +/// Every query sets `kSecUseDataProtectionKeychain`. Without it, `kSecAttrAccessible` is +/// ignored on macOS (it applies only to data-protection or synchronizable items), so the +/// declared accessibility class would be decorative. Setting it on *all* operations also +/// matters for correctness: a data-protection item is invisible to a query that omits +/// the flag, so a mixed set of queries would fail to find or delete its own items. +public enum Keychain { + public static let service = "com.opencodex.menubar.apikey" + public static let defaultAccount = "default" + + private static func baseQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + } + + public static func read(account: String = defaultAccount) -> String? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8), + !value.isEmpty + else { return nil } + return value + } + + @discardableResult + public static func write(_ value: String, account: String = defaultAccount) -> Bool { + let data = Data(value.utf8) + + // Update first, add only when absent. Deleting first would destroy a working key + // whenever the subsequent add failed. + let updateStatus = SecItemUpdate( + baseQuery(account: account) as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return true } + guard updateStatus == errSecItemNotFound else { return false } + + var attributes = baseQuery(account: account) + attributes[kSecValueData as String] = data + // ThisDeviceOnly: the key is a local proxy credential with no reason to migrate + // to another machine via backup or transfer. + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess + } + + @discardableResult + public static func delete(account: String = defaultAccount) -> Bool { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/app/Sources/MenuBarCore/MenuBarTitle.swift b/app/Sources/MenuBarCore/MenuBarTitle.swift new file mode 100644 index 00000000000..c17613e3c85 --- /dev/null +++ b/app/Sources/MenuBarCore/MenuBarTitle.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum MenuBarTitle { + public static func render( + settings: CompanionSettings, + today: UsageReport?, + quotas: [NormalizedQuota] + ) -> String? { + let summary = today?.summary + let values: [String: String] = [ + "requests": Format.count(summary?.requests), + "totalTokens": Format.tokens(summary?.totalTokens), + "inputTokens": Format.tokens(summary?.inputTokens), + "outputTokens": Format.tokens(summary?.outputTokens), + "costUsd": Format.cost(summary?.estimatedCostUsd), + "quotaPercent": Format.percent(quotas.compactMap(\.percent).min()), + ] + let rendered: String + if let template = settings.menuBarTemplate?.trimmingCharacters(in: .whitespacesAndNewlines), + !template.isEmpty { + rendered = values.reduce(template) { text, item in + text.replacingOccurrences(of: "{\(item.key)}", with: item.value) + } + } else { + switch settings.menuBarMetric { + case .requests: rendered = Format.count(summary?.requests) + case .tokens: rendered = Format.tokens(summary?.totalTokens) + case .cost: rendered = Format.cost(summary?.estimatedCostUsd) + case .quota: rendered = Format.percent(quotas.compactMap(\.percent).min()) + case .none: return nil + } + } + let text = rendered.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + if text.count <= 24 { return text } + return String(text.prefix(23)) + "…" + } +} diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift new file mode 100644 index 00000000000..4125657a62b --- /dev/null +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -0,0 +1,268 @@ +import Foundation + +/// Owns the refresh schedule and turns transport results into a `ProxySnapshot`. +/// +/// Polling is deliberately conservative. A menu bar app that hits a local server every +/// five seconds forever is a battery complaint waiting to happen, so heavy aggregation +/// endpoints are fetched only while the popover is open, and repeated failures back the +/// liveness tick off rather than hammering a proxy the user has stopped on purpose. +public actor PollingCoordinator { + public static let livenessInterval: TimeInterval = 5 + public static let heavyInterval: TimeInterval = 60 + public static let backoffInterval: TimeInterval = 30 + public static let backoffAfterFailures = 3 + + private let client: ProxyClient + private var snapshot: ProxySnapshot + private var popoverOpen = false + private var observers: [UUID: @Sendable (ProxySnapshot) -> Void] = [:] + /// Rises on every close and on every new refresh, so results from a superseded or + /// abandoned cycle can be discarded instead of overwriting fresher state. + private var generation = 0 + private var refreshInFlight = false + /// A refresh requested while another was in flight. Without this, closing and + /// immediately reopening the popover dropped the reopen's refresh entirely: the old + /// cycle exited on its generation guard and the new one had already been rejected. + private var pendingOpenRefresh = false + /// Continuations waiting for a cycle to publish. Waiting on a real completion signal + /// rather than a bounded spin means a slow-but-legitimate refresh cannot be + /// abandoned early, which would re-enable a control against pre-write state. + private var completionWaiters: [CheckedContinuation] = [] + /// Attempt time, distinct from success time: a persistently failing endpoint must + /// not turn its healthy sibling into a 5-second poller. + private var lastAggregationAttempt: Date? + + public init(client: ProxyClient, endpoint: ProxyEndpoint) { + self.client = client + self.snapshot = ProxySnapshot(endpoint: endpoint) + } + + public var current: ProxySnapshot { snapshot } + + /// Interval until the next liveness tick, widened once failures pile up. + public var currentInterval: TimeInterval { + snapshot.consecutiveFailures >= Self.backoffAfterFailures + ? Self.backoffInterval + : Self.livenessInterval + } + + @discardableResult + public func observe(_ handler: @escaping @Sendable (ProxySnapshot) -> Void) -> UUID { + let token = UUID() + observers[token] = handler + handler(snapshot) + return token + } + + public func removeObserver(_ token: UUID) { observers[token] = nil } + + public func setPopoverOpen(_ open: Bool) async { + popoverOpen = open + if open { + await refresh(includeHeavy: true) + } else { + // Abandon in-flight heavy work: its results are no longer visible and + // must not land as if they were current. + generation &+= 1 + } + } + + /// One refresh cycle. + /// + /// `includeHeavy` marks a popover-open refresh: on-open reads (providers, config) + /// always run, while the expensive aggregation reads (usage, quotas) still respect + /// the 60s interval so reopening the popover repeatedly does not hammer the proxy. + public func refresh(includeHeavy: Bool = false) async { + // Overlapping cycles publish interleaved state and double the request rate. + guard !refreshInFlight else { + if includeHeavy { pendingOpenRefresh = true } + return + } + refreshInFlight = true + generation &+= 1 + let cycle = generation + defer { refreshInFlight = false } + + do { + let health = try await client.health() + guard cycle == generation else { + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } + snapshot.state = .running(health) + snapshot.lastKnownStartCommand = health.manualStartCommand + snapshot.recommendedCommand = health.recommendedCommand + snapshot.consecutiveFailures = 0 + snapshot.lastUpdated = Date() + } catch is CancellationError { + // The popover closed mid-flight. Not a proxy failure; leave state untouched. + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } catch let error as ProxyError { + if cycle == generation { apply(error); publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } catch { + if cycle == generation { apply(.transport); publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } + + if popoverOpen, includeHeavy { + await refreshOnOpen(cycle: cycle) + } + + // Settings and today metrics also drive the menu-bar title, so aggregation runs + // on the normal cadence even while the popover is closed. + let aggregationDue = lastAggregationAttempt.map { + Date().timeIntervalSince($0) >= Self.heavyInterval + } ?? true + if aggregationDue, isCurrentCycle(cycle) { + lastAggregationAttempt = Date() + _ = await refreshAggregation(cycle: cycle) + } + + if cycle == generation { publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + } + + /// Refreshes and does not return until a cycle has actually published. + /// + /// `refresh()` coalesces: if another cycle holds the lock it queues and returns + /// immediately. A caller that needs authoritative state afterwards — such as + /// re-enabling a switch after a write — would otherwise act on pre-write data. + public func refreshAndWait(includeHeavy: Bool = true) async { + if refreshInFlight { + // Queue behind the running cycle and wait for the queued one to finish. + await refresh(includeHeavy: includeHeavy) + await waitForCompletion() + return + } + await refresh(includeHeavy: includeHeavy) + } + + /// Number of callers currently suspended in `waitForCompletion()`. + /// + /// Exposed so a test can wait for registration deterministically instead of sleeping + /// and hoping the waiter task was scheduled — a fixed sleep let the continuation + /// tests pass without ever entering this path. + package var waiterCount: Int { completionWaiters.count } + + private func waitForCompletion() async { + guard refreshInFlight || pendingOpenRefresh else { return } + await withCheckedContinuation { continuation in + completionWaiters.append(continuation) + } + } + + /// Releases anyone waiting once no cycle is running or queued. + private func signalCompletionIfIdle() { + guard !refreshInFlight, !pendingOpenRefresh, !completionWaiters.isEmpty else { return } + let waiters = completionWaiters + completionWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + /// Runs a refresh that arrived while another cycle held the lock. + private func drainPendingRefresh() async { + guard pendingOpenRefresh, popoverOpen else { + pendingOpenRefresh = false + return + } + pendingOpenRefresh = false + await refresh(includeHeavy: true) + } + + /// Reads that are only meaningful while the popover is open. + private func refreshOnOpen(cycle: Int) async { + guard isCurrent(cycle) else { return } + if let providers = try? await client.providers(), isCurrent(cycle) { + snapshot.providers = providers + snapshot.providersLoaded = true + } + // Re-check before each subsequent request: closing mid-flight should stop the + // sequence, not merely discard its results after paying for them. + guard isCurrent(cycle) else { return } + if let config = try? await client.config(), isCurrent(cycle) { + snapshot.defaultProvider = config.defaultProvider + } + } + + /// Still the newest cycle, and still worth doing. + private func isCurrent(_ cycle: Int) -> Bool { cycle == generation && popoverOpen } + private func isCurrentCycle(_ cycle: Int) -> Bool { cycle == generation } + + /// The expensive aggregation reads. Returns whether every read landed, so a partial + /// failure does not masquerade as a completed refresh. + private func refreshAggregation(cycle: Int) async -> Bool { + guard isCurrentCycle(cycle) else { return false } + var complete = true + + // Each read is independent: one failing endpoint must not blank the others. + if let response = try? await client.companionSettings() { + guard isCurrentCycle(cycle) else { return false } + snapshot.settings = response.settings + snapshot.settingsLoaded = true + } else { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if let today = try? await client.usage(range: .today) { + guard isCurrentCycle(cycle) else { return false } + snapshot.today = today + snapshot.usage = today + snapshot.usageUpdated = Date() + } else { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if snapshot.settings.showChart, let timeline = try? await client.timeline(snapshot.settings) { + guard isCurrentCycle(cycle) else { return false } + snapshot.timeline = timeline + snapshot.timelineUpdated = Date() + } else if snapshot.settings.showChart { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if (popoverOpen || snapshot.settings.menuBarMetric == .quota), let quotas = try? await client.quotas() { + guard isCurrent(cycle) else { return false } + snapshot.quotas = quotas + snapshot.quotasLoaded = true + } else if popoverOpen || snapshot.settings.menuBarMetric == .quota { + complete = false + } + + return complete + } + + private func apply(_ error: ProxyError) { + snapshot.consecutiveFailures += 1 + switch error { + case .unreachable: + snapshot.state = .unreachable + case .unauthorized: + snapshot.state = .unauthorized + case .http, .decoding, .transport, .inconclusive: + // A timeout is degraded, not stopped: something may well still be running. + snapshot.state = .degraded(error.userMessage) + } + } + + private func publish() { + let value = snapshot + for handler in observers.values { handler(value) } + } +} diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift new file mode 100644 index 00000000000..53188278140 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -0,0 +1,332 @@ +import Foundation + +public enum ProxyError: Error, Equatable { + /// The connection was refused — nothing is listening. This is the only transport + /// result that proves the proxy is gone; timeouts get `.inconclusive`. + case unreachable + /// 401 — a non-loopback bind that requires a credential. + case unauthorized + case http(Int) + case decoding + /// A transport failure that is not evidence the proxy is down (TLS, policy, and + /// other non-connectivity URLSession errors). + case transport + /// The request never completed — a timeout or a socket dropped mid-response. This + /// proves nothing either way, and must not be read as "the proxy is gone". + case inconclusive + + /// Human sentences only. Response bodies can echo configuration values, so they + /// never reach the UI or a log. + public var userMessage: String { + switch self { + case .unreachable: return "The proxy is not running." + case .unauthorized: return "This proxy requires an API key." + case .http(let code): return "The proxy returned an unexpected status (\(code))." + case .decoding: return "The proxy returned a response this app could not read." + case .transport: return "The connection to the proxy failed." + case .inconclusive: return "The proxy did not respond in time." + } + } +} + +/// Supplies the optional management API key. Injected so tests never touch the real +/// Keychain and so the app can swap the source without touching transport code. +public protocol CredentialStore: Sendable { + func loadAPIKey() -> String? +} + +public struct KeychainCredentialStore: CredentialStore { + public init() {} + public func loadAPIKey() -> String? { Keychain.read() } +} + +/// HTTP client for the OpenCodex management API. +/// +/// An actor because the endpoint and key are mutated from both the polling loop and user +/// actions; the isolation makes that data-race-free by construction rather than by +/// convention. +public actor ProxyClient { + private let session: URLSession + private let credentials: CredentialStore + private var endpoint: ProxyEndpoint + private var apiKey: String? + /// Ensures the lazy credential load happens at most once per client. + private var didAttemptCredentialLoad = false + + public init( + endpoint: ProxyEndpoint, + session: URLSession? = nil, + credentials: CredentialStore = KeychainCredentialStore() + ) { + self.endpoint = endpoint + self.credentials = credentials + if let session { + self.session = session + } else { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 4 + config.waitsForConnectivity = false + self.session = URLSession(configuration: config) + } + } + + public var currentEndpoint: ProxyEndpoint { endpoint } + + public func updateEndpoint(_ endpoint: ProxyEndpoint) { self.endpoint = endpoint } + + public func setAPIKey(_ key: String?) { + self.apiKey = key + // An explicitly supplied key replaces the lazy path entirely. + self.didAttemptCredentialLoad = true + } + + // MARK: - Reads + + public func health() async throws -> StartupHealth { try await get("api/startup-health") } + public func settings() async throws -> ProxySettings { try await get("api/settings") } + public func config() async throws -> ProxyConfigSummary { try await get("api/config") } + public func providers() async throws -> [ProviderSummary] { try await get("api/providers") } + + public func usage(range: UsageRange = .sevenDays) async throws -> UsageReport { + try await get("api/usage", query: [URLQueryItem(name: "range", value: range.rawValue)]) + } + + public func companionSettings() async throws -> CompanionSettingsResponse { + try await get("api/companion/settings") + } + + public func timeline(_ settings: CompanionSettings) async throws -> UsageTimeline { + var query = [ + URLQueryItem(name: "hours", value: String(settings.chartHours)), + URLQueryItem(name: "bucketMinutes", value: String(settings.bucketMinutes)), + URLQueryItem(name: "metric", value: settings.tokenMetric.rawValue), + URLQueryItem(name: "aggregation", value: settings.aggregation.rawValue), + URLQueryItem(name: "grouping", value: settings.chartGrouping.rawValue), + ] + if let models = settings.models, !models.isEmpty { + query.append(URLQueryItem(name: "models", value: models.joined(separator: ","))) + } + return try await get("api/usage/timeline", query: query) + } + + public func quotas() async throws -> [QuotaReport] { + let envelope: QuotaEnvelope = try await get("api/provider-quotas") + return envelope.reports ?? [] + } + + /// What a liveness probe actually established. + /// + /// Three states, not two. "Did not get a usable answer" and "nothing is listening" + /// are different facts, and conflating them let a stop be reported as confirmed + /// while an HTTP server was still running behind a 500 or a decode failure. + public enum Liveness: Equatable, Sendable { + /// Something answered — any HTTP status, including 401/403/500, or a body we + /// could not decode. The port is occupied. + case reachable + /// The connection was refused. This is the only proof that the proxy is gone. + case refused + /// A timeout or other transport failure: no conclusion either way. + case indeterminate + } + + /// A short probe: the default 4s read timeout would let a single liveness check + /// overrun the stop deadline it is supposed to respect. + public func liveness(timeout: TimeInterval = 1.5) async -> Liveness { + do { + // Deliberately bypasses `send()`: its 401 credential retry would spend a + // second full timeout re-asking a question the 401 already answered, and a + // failed retry would downgrade a known-reachable result to indeterminate. + _ = try await perform( + method: "GET", path: "api/settings", query: [], + body: nil as EmptyBody?, timeout: timeout + ) + return .reachable + } catch ProxyError.unauthorized, ProxyError.decoding { + // Both prove a server answered. + return .reachable + } catch ProxyError.http { + return .reachable + } catch ProxyError.unreachable { + // Connection refused: nothing is listening on the port. + return .refused + } catch { + // Timeouts, dropped sockets, and anything else: no conclusion. + return .indeterminate + } + } + + /// Convenience for callers that only need "is anything there". + public func isReachable() async -> Bool { + await liveness() != .refused + } + + // MARK: - Writes + + /// `POST /api/stop`. Returns once the proxy has accepted the request. + /// + /// The proxy answers 200 *before* draining, and it stops the launchd service first so + /// nothing respawns it. Callers must poll `liveness()` rather than treat this return + /// as "stopped". + /// + /// The response carries `success: false` when `restoreNativeCodex()` failed + /// (`src/server/management-api.ts:145-147`): the proxy still shuts down, but native + /// Codex was left pointing at a port that is about to close. Only the boolean is + /// decoded — the accompanying message is a server-formatted string and never reaches + /// the UI. + @discardableResult + public func stop() async throws -> Bool { + let data = try await send(method: "POST", path: "api/stop", body: nil as EmptyBody?) + guard let result = try? JSONDecoder().decode(StopResult.self, from: data) else { + // An undecodable body is not a reason to claim the restore failed. + return true + } + return result.success ?? true + } + + /// `PATCH /api/providers?name=` with a body of exactly `{"disabled": }`. + /// + /// A disabled-only patch skips the proxy's heavier merged-shape validators, so adding + /// any second field would silently change the request class. + public func setProviderDisabled(_ name: String, disabled: Bool) async throws { + _ = try await send( + method: "PATCH", + path: "api/providers", + query: [URLQueryItem(name: "name", value: name)], + body: ProviderDisabledPatch(disabled: disabled) + ) + } + + // MARK: - Transport + + private func get( + _ path: String, + query: [URLQueryItem] = [], + timeout: TimeInterval? = nil + ) async throws -> T { + let data = try await send( + method: "GET", path: path, query: query, + body: nil as EmptyBody?, timeout: timeout + ) + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ProxyError.decoding + } + } + + private func send( + method: String, + path: String, + query: [URLQueryItem] = [], + body: Body?, + timeout: TimeInterval? = nil + ) async throws -> Data { + let keyAtStart = apiKey + do { + return try await perform(method: method, path: path, query: query, body: body, timeout: timeout) + } catch ProxyError.unauthorized { + // A loopback proxy needs no credential, so a 401 means this install is bound + // to a non-loopback host. + // + // Reentrancy matters here: the actor suspends across the request, so several + // calls can be in flight and all receive 401. Retry eligibility is therefore + // decided per request, against the key THAT request actually sent — not + // against a single global "already tried" flag. A concurrent caller that + // started before the key was loaded must still get to retry with it. + guard let key = try await credentialForRetry(after: keyAtStart) else { + throw ProxyError.unauthorized + } + return try await perform(method: method, path: path, query: query, body: body, key: key, timeout: timeout) + } + } + + /// The key to retry with, or `nil` when this request already used the current + /// credential (so retrying would repeat an identical, failing call). + private func credentialForRetry(after keyAtStart: String?) async throws -> String? { + // Another in-flight call already loaded a key this request did not use. + if let current = apiKey, current != keyAtStart { return current } + // This request already carried the newest key: a stale credential, not a + // missing one. Never loop. + if apiKey != nil, apiKey == keyAtStart { return nil } + + guard !didAttemptCredentialLoad else { return nil } + didAttemptCredentialLoad = true + guard let stored = credentials.loadAPIKey(), !stored.isEmpty else { return nil } + apiKey = stored + return stored + } + + private func perform( + method: String, + path: String, + query: [URLQueryItem], + body: Body?, + key: String? = nil, + timeout: TimeInterval? = nil + ) async throws -> Data { + guard var components = URLComponents( + url: endpoint.baseURL.appendingPathComponent(path), + resolvingAgainstBaseURL: false + ) else { throw ProxyError.decoding } + if !query.isEmpty { components.queryItems = query } + guard let url = components.url else { throw ProxyError.decoding } + + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = timeout ?? (method == "GET" ? 4 : 6) + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + request.setValue("OpenCodexMenuBar/\(version)", forHTTPHeaderField: "User-Agent") + if let credential = key ?? apiKey { + request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") + } + if let body { + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try? JSONEncoder().encode(body) + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ProxyError.decoding } + if http.statusCode == 401 { throw ProxyError.unauthorized } + guard (200..<300).contains(http.statusCode) else { + throw ProxyError.http(http.statusCode) + } + return data + } catch let error as ProxyError { + throw error + } catch let error as URLError { + switch error.code { + case .cancelled: + // Propagate cancellation rather than reporting a stopped proxy: the + // polling coordinator cancels in-flight work whenever the popover closes. + throw CancellationError() + case .cannotConnectToHost: + // The one code that actually proves nothing is listening. + throw ProxyError.unreachable + case .timedOut, .networkConnectionLost, .cannotFindHost, + .notConnectedToInternet, .dnsLookupFailed: + // A timeout or a dropped socket says the request failed, not that the + // server is gone. Collapsing these into `.unreachable` is what let a + // stop be reported as confirmed while the proxy was still running. + throw ProxyError.inconclusive + default: + throw ProxyError.transport + } + } + } +} + +private struct QuotaEnvelope: Decodable { + let generatedAt: Double? + let reports: [QuotaReport]? +} + +private struct ProviderDisabledPatch: Encodable { + let disabled: Bool +} + +private struct StopResult: Decodable { + let success: Bool? +} + +private struct EmptyBody: Encodable {} diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift new file mode 100644 index 00000000000..b79034e394b --- /dev/null +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -0,0 +1,285 @@ +import Foundation + +// Codable mirrors of the management API payloads inventoried in +// devlog/_plan/260725_macos_menubar_app/002_api_surface.md. +// +// Every field the proxy may omit is optional. The proxy is a fast-moving local service; +// a companion that fails to decode because one field moved is worse than one that shows +// an em dash. + +/// `GET /api/startup-health` +public struct StartupHealth: Decodable, Equatable, Sendable { + public let status: String? + public let protection: String? + public let platform: String? + public let routingKind: String? + public let serviceRunning: Bool? + public let serviceInstalled: Bool? + public let serviceEnabled: Bool? + public let rebootSafe: Bool? + public let recommendedCommand: String? + + public init( + status: String? = nil, + protection: String? = nil, + platform: String? = nil, + routingKind: String? = nil, + serviceRunning: Bool? = nil, + serviceInstalled: Bool? = nil, + serviceEnabled: Bool? = nil, + rebootSafe: Bool? = nil, + recommendedCommand: String? = nil + ) { + self.status = status + self.protection = protection + self.platform = platform + self.routingKind = routingKind + self.serviceRunning = serviceRunning + self.serviceInstalled = serviceInstalled + self.serviceEnabled = serviceEnabled + self.rebootSafe = rebootSafe + self.recommendedCommand = recommendedCommand + } + + /// `status` is treated as an open string: unknown values degrade to a neutral state + /// rather than crashing or being coerced into "healthy". + public var isProtected: Bool { status == "protected" } + + /// True when a supervisor owns the process lifecycle. Used only for the qualifier + /// line — it deliberately does not gate any action, because `/api/stop` stops the + /// service on purpose and nothing restarts the proxy automatically. + public var isServiceManaged: Bool { + (serviceInstalled ?? false) && (serviceEnabled ?? false) + } + + /// The command to show the user when the proxy is not running. + public var manualStartCommand: String { + isServiceManaged ? "ocx service start" : "ocx start" + } +} + +/// `GET /api/settings`. Note the absence of `defaultProvider` — it lives on +/// `/api/config`, verified against the live key set. +public struct ProxySettings: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let streamMode: String? + public let codexAutoStart: Bool? +} + +/// `GET /api/config` — the only source of `defaultProvider`. +public struct ProxyConfigSummary: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let defaultProvider: String? +} + +/// Ranges accepted by `parseRange()` in `src/usage/summary.ts`. +/// +/// Closed on purpose: the server silently degrades anything else to `30d`, so a +/// stringly-typed range would let a caller ask for `24h`, receive thirty days of data, +/// and label it wrongly. +public enum UsageRange: String, Sendable, CaseIterable { + case today = "today" + case sevenDays = "7d" + case thirtyDays = "30d" + case all +} + +public struct UsageSummary: Decodable, Equatable, Sendable { + public let requests: Int? + public let measuredRequests: Int? + public let estimatedRequests: Int? + public let totalTokens: Int? + public let inputTokens: Int? + public let outputTokens: Int? + public let estimatedCostUsd: Double? + public let coverageRatio: Double? + + public var hasEstimates: Bool { (estimatedRequests ?? 0) > 0 } +} + +public struct UsageDay: Decodable, Equatable, Sendable { + public let date: String + public let requests: Int? + public let totalTokens: Int? +} + +public struct UsageReport: Decodable, Equatable, Sendable { + public let range: String? + public let surface: String? + public let generatedAt: Double? + public let summary: UsageSummary? + public let days: [UsageDay]? + public let models: [UsageModelRow]? + public let accounts: [UsageAccountRow]? + + /// The range the server actually applied, which is not always the one requested. + public var effectiveRange: UsageRange? { + range.flatMap(UsageRange.init(rawValue:)) + } + + /// Header text driven by the response, never by the request. + public var rangeLabel: String { + switch effectiveRange { + case .today: return "TODAY" + case .sevenDays: return "LAST 7 DAYS" + case .thirtyDays: return "LAST 30 DAYS" + case .all: return "ALL TIME" + case nil: return "USAGE" + } + } + + public var isEmpty: Bool { + isEmptyOrUnknown == true + } + + /// Three states, not two: `nil` means the proxy did not report a request count, and + /// `true` means it explicitly reported zero. Collapsing those would let the UI print + /// "No requests" for data it simply does not have. + public var isEmptyOrUnknown: Bool? { + guard let requests = summary?.requests else { return nil } + return requests == 0 + } +} + +public struct UsageModelRow: Decodable, Equatable, Sendable { + public let provider: String? + public let model: String? + public let requests: Int? + public let totalTokens: Int? + public let estimatedCostUsd: Double? +} + +public struct UsageAccountRow: Decodable, Equatable, Sendable { + public let accountLogLabel: String? + public let requests: Int? + public let totalTokens: Int? + public let estimatedCostUsd: Double? +} + +public struct QuotaWindow: Decodable, Equatable, Sendable { + public let label: String? + public let percent: Double? + public let resetAt: Double? +} + +public struct ProviderQuota: Decodable, Equatable, Sendable { + public let weeklyPercent: Double? + public let monthlyPercent: Double? + public let fiveHourPercent: Double? + public let weeklyResetAt: Double? + public let monthlyResetAt: Double? + public let fiveHourResetAt: Double? + public let customWindows: [QuotaWindow]? + public let updatedAt: Double? +} + +public struct QuotaReport: Decodable, Equatable, Sendable { + public let provider: String + public let label: String? + public let source: String? + public let quota: ProviderQuota? +} + +/// A provider-agnostic view of quota, since the window key differs per provider. +public struct NormalizedQuota: Equatable, Sendable { + public let provider: String + public let providerLabel: String + public let percent: Double? + public let windowLabel: String + public let resetAt: Date? + + public var hasPercent: Bool { percent != nil } +} + +public extension QuotaReport { + /// Timestamps in this payload are not uniform: the live proxy returns + /// `weeklyResetAt` in seconds for `openai` and in milliseconds for `anthropic`, + /// within the same array. Disambiguate by magnitude — 1e12 is 2001 read as + /// milliseconds and year 33658 read as seconds, so the boundary is unambiguous for + /// any timestamp this app will ever see. + static func date(from value: Double?) -> Date? { + guard let value, value > 0 else { return nil } + let seconds = value >= 1_000_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + /// Every window the provider reported, in display order. + /// + /// The live proxy is not uniform: `openai` and `xai` report a single named window, + /// `kimi` reports both `weeklyPercent` and `fiveHourPercent`, and `cursor` and + /// `google-antigravity` carry two `customWindows` each. Returning only one window + /// would silently hide real quota pressure. + func normalizedWindows() -> [NormalizedQuota] { + let name = label ?? provider + var windows: [NormalizedQuota] = [] + + func append(_ percent: Double?, _ windowLabel: String, _ resetAt: Double?) { + guard percent != nil || resetAt != nil else { return } + windows.append(NormalizedQuota( + provider: provider, providerLabel: name, percent: percent, + windowLabel: windowLabel, resetAt: Self.date(from: resetAt) + )) + } + + append(quota?.fiveHourPercent, "5h", quota?.fiveHourResetAt) + append(quota?.weeklyPercent, "week", quota?.weeklyResetAt) + append(quota?.monthlyPercent, "month", quota?.monthlyResetAt) + + for window in quota?.customWindows ?? [] { + append(window.percent, window.label ?? "window", window.resetAt) + } + + return windows + } + + /// The single window that best represents current pressure, for the compact row. + /// + /// Selection is **highest reported usage**, not longest horizon. Every window can + /// stop work: a provider at 99% of a five-hour limit and 10% of its monthly limit is + /// blocked right now, and showing the monthly 10% would paint that row green while + /// the user cannot make a request. Ties break toward the longer horizon, since that + /// is the one that will not recover on its own. + /// + /// Providers with no numeric window normalize to a nil percent so the UI renders an + /// em dash rather than a misleading zero. + func normalized() -> NormalizedQuota { + let name = label ?? provider + let windows = normalizedWindows() + + // Longer horizons rank higher only as a tie-breaker. + func horizonRank(_ label: String) -> Int { + switch label { + case "month": return 3 + case "week": return 2 + case "5h": return 1 + default: return 0 + } + } + + let measured = windows.filter(\.hasPercent) + let preferred = measured.max { lhs, rhs in + let left = lhs.percent ?? 0 + let right = rhs.percent ?? 0 + if left != right { return left < right } + return horizonRank(lhs.windowLabel) < horizonRank(rhs.windowLabel) + } ?? windows.first + + return preferred ?? NormalizedQuota( + provider: provider, providerLabel: name, percent: nil, + windowLabel: "—", resetAt: nil + ) + } +} + +/// `GET /api/providers`. `hasApiKey` is a presence flag; the key never leaves the proxy. +public struct ProviderSummary: Decodable, Equatable, Sendable { + public let name: String + public let adapter: String? + public let authMode: String? + public let hasApiKey: Bool? + public let disabled: Bool? + + public var isEnabled: Bool { !(disabled ?? false) } +} diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift new file mode 100644 index 00000000000..30416a5a7f9 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Everything the UI can show, as one value. +/// +/// Views are pure functions of this snapshot, so no view invents its own loading flag or +/// decides independently whether data is missing. +public enum ProxyState: Equatable, Sendable { + /// First fetch in flight; nothing is known yet. + case loading + case running(StartupHealth) + /// Connection refused — the proxy is not running. + case unreachable + /// 401 with no usable credential. + case unauthorized + /// Reachable but erroring. The message is proxy-free human text. + case degraded(String) + + public var isRunning: Bool { + if case .running = self { return true } + return false + } + + /// Short label shown beside the status dot. Colour is never the only carrier of + /// meaning, so every state has a word. + public var title: String { + switch self { + case .loading: return "Checking…" + case .running: return "Running" + case .unreachable: return "Stopped" + case .unauthorized: return "Needs API key" + case .degraded: return "Degraded" + } + } + + public enum Tone: Sendable { case neutral, good, warning, bad } + + public var tone: Tone { + switch self { + case .loading: return .neutral + case .running(let health): return health.isProtected ? .good : .warning + case .unreachable: return .bad + case .unauthorized: return .warning + case .degraded: return .warning + } + } + + /// Secondary line under the title. + public var detail: String? { + switch self { + case .loading: + return nil + case .running(let health): + let parts = [health.status, health.protection] + .compactMap { $0 } + .filter { !$0.isEmpty && $0 != "none" } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + case .unreachable: + return "The proxy is not running." + case .unauthorized: + return "This proxy requires an API key." + case .degraded(let message): + return message + } + } +} + +/// What the user should do next. `loading` deliberately has none — there is nothing to +/// act on yet — but every other non-running state names one. +public enum NextAction: Equatable, Sendable { + case none + /// A command to run, shown as selectable text. The app never spawns processes. + case runCommand(String) + case addAPIKey + case retry +} + +public struct ProxySnapshot: Equatable, Sendable { + public var state: ProxyState + public var endpoint: ProxyEndpoint + public var usage: UsageReport? + public var settings: CompanionSettings + public var settingsLoaded: Bool + public var today: UsageReport? + public var timeline: UsageTimeline? + public var timelineUpdated: Date? + public var quotas: [QuotaReport] + public var providers: [ProviderSummary] + public var defaultProvider: String? + public var lastUpdated: Date? + public var consecutiveFailures: Int + /// Remembered from the last successful health read, so a stopped proxy can still + /// tell the user the right start command for their install. + public var lastKnownStartCommand: String? + /// The proxy's own remediation hint (for example `ocx service install`). Displayed + /// as selectable text, never executed. + public var recommendedCommand: String? + /// Whether a section has actually been read, so "not fetched yet" and "the proxy + /// reported none" render differently. + public var providersLoaded: Bool + public var quotasLoaded: Bool + /// When the aggregation data last succeeded, which is NOT when health last + /// succeeded. Conflating them let a degraded state claim "showing data from 5s ago" + /// while holding no metrics at all. + public var usageUpdated: Date? + + public init( + state: ProxyState = .loading, + endpoint: ProxyEndpoint, + usage: UsageReport? = nil, + settings: CompanionSettings = .defaults, + settingsLoaded: Bool = false, + today: UsageReport? = nil, + timeline: UsageTimeline? = nil, + timelineUpdated: Date? = nil, + quotas: [QuotaReport] = [], + providers: [ProviderSummary] = [], + defaultProvider: String? = nil, + lastUpdated: Date? = nil, + consecutiveFailures: Int = 0, + lastKnownStartCommand: String? = nil, + recommendedCommand: String? = nil, + providersLoaded: Bool = false, + quotasLoaded: Bool = false, + usageUpdated: Date? = nil + ) { + self.state = state + self.endpoint = endpoint + self.usage = usage + self.settings = settings + self.settingsLoaded = settingsLoaded + self.today = today + self.timeline = timeline + self.timelineUpdated = timelineUpdated + self.quotas = quotas + self.providers = providers + self.defaultProvider = defaultProvider + self.lastUpdated = lastUpdated + self.consecutiveFailures = consecutiveFailures + self.lastKnownStartCommand = lastKnownStartCommand + self.recommendedCommand = recommendedCommand + self.providersLoaded = providersLoaded + self.quotasLoaded = quotasLoaded + self.usageUpdated = usageUpdated + } + + /// Whether the data sections are worth rendering at all. + /// + /// `degraded` keeps them: the plan requires stale-but-labelled over blank, because a + /// user who can still see last-known numbers with an explicit age is better served + /// than one staring at an empty panel. + public var showsData: Bool { + switch state { + case .running: return true + // Only claim stale data when data was actually loaded. Health succeeding while + // the popover was closed is not the same as having metrics to show. + case .degraded: return usage != nil || quotasLoaded + case .loading, .unreachable, .unauthorized: return false + } + } + + /// Age of the DATA, not of the last health probe. + public var dataAge: Date? { usageUpdated } + + /// True once the proxy has been read at least once, so `loading` can show skeletons + /// rather than empty copy. + public var hasEverLoaded: Bool { lastUpdated != nil } + + public var nextAction: NextAction { + switch state { + case .loading: return .none + case .running: return .none + case .unreachable: + return .runCommand(lastKnownStartCommand ?? "ocx start") + case .unauthorized: return .addAPIKey + case .degraded: return .retry + } + } + + /// One normalized row per provider for the compact quota list. + public var quotaRows: [NormalizedQuota] { + quotas.map { $0.normalized() } + } + + public var visibleProviders: [ProviderSummary] { + providers.filter { !settings.hiddenProviders.contains($0.name) } + } + + public var menuBarTitle: String? { + MenuBarTitle.render(settings: settings, today: today ?? usage, quotas: quotaRows) + } + + public var todayRows: [UsageModelRow] { today?.models ?? [] } + + /// Whether the metrics section should render its empty copy. `nil` means unknown, + /// which renders em dashes instead. + public var usageIsEmpty: Bool? { usage?.isEmptyOrUnknown } + + public func canToggle(_ provider: ProviderSummary) -> Bool { + provider.name != defaultProvider + } +} diff --git a/app/Sources/MenuBarCore/UsageTimeline.swift b/app/Sources/MenuBarCore/UsageTimeline.swift new file mode 100644 index 00000000000..6291272bcc2 --- /dev/null +++ b/app/Sources/MenuBarCore/UsageTimeline.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct TimelineSeries: Decodable, Equatable, Sendable { + public let id: String + public let provider: String + public let model: String + public let accountLogLabel: String? + public let total: Double + public let points: [Double] +} + +public struct UsageTimeline: Decodable, Equatable, Sendable { + public let start: Double + public let end: Double + public let bucketSeconds: Int + public let buckets: Int + public let metric: String + public let aggregation: String + public let grouping: String + public let series: [TimelineSeries] + public let availableModels: [String] + public let missingMeasurements: Int + public let truncated: Bool? + + public var maxPoint: Double { + series.flatMap(\.points).max() ?? 0 + } + + public var stackedMax: Double { + guard buckets > 0 else { return 0 } + return (0.. WidgetSnapshot { + let state: String + switch snapshot.state { + case .loading: state = "loading" + case .running: state = "running" + case .unreachable: state = "unreachable" + case .unauthorized: state = "unauthorized" + case .degraded: state = "degraded" + } + let report = snapshot.today ?? snapshot.usage + let today = report?.summary.map { + Today(requests: $0.requests, totalTokens: $0.totalTokens, estimatedCostUsd: $0.estimatedCostUsd) + } + let quotas = snapshot.quotaRows.map { + Quota(providerLabel: $0.providerLabel, windowLabel: $0.windowLabel, percent: $0.percent, resetAt: $0.resetAt?.timeIntervalSince1970) + } + let chart = snapshot.timeline.map { + Chart( + start: $0.start, bucketSeconds: $0.bucketSeconds, style: snapshot.settings.chartStyle.rawValue, + series: Array($0.series.prefix(6)).map { Chart.Series(id: $0.id, points: $0.points) } + ) + } + return WidgetSnapshot( + schemaVersion: 1, generatedAt: now.timeIntervalSince1970, + state: state, stateTitle: snapshot.state.title, detail: snapshot.state.detail, + endpointDisplay: snapshot.endpoint.display, menuTitle: snapshot.menuBarTitle, + today: today, quotas: quotas, chart: chart, + lastUpdated: (snapshot.timelineUpdated ?? snapshot.usageUpdated)?.timeIntervalSince1970 + ) + } +} + +public final class WidgetSnapshotStore: @unchecked Sendable { + private let fileManager: FileManager + private let homeDirectory: URL + private let widgetBundleID: String + private let lock = NSLock() + private var lastWritten: WidgetSnapshot? + private let logger = Logger(subsystem: "ai.opencodex.menubar", category: "widget-snapshot") + private var loggedFailures = Set() + + public init( + widgetBundleID: String = "com.opencodex.menubar.widget", + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) { + self.widgetBundleID = widgetBundleID + self.fileManager = fileManager + self.homeDirectory = homeDirectory + } + + public var url: URL { + homeDirectory + .appendingPathComponent("Library/Containers/\(widgetBundleID)/Data/Library/Application Support/OpenCodex", isDirectory: true) + .appendingPathComponent("snapshot.json") + } + + public func write(_ snapshot: WidgetSnapshot) throws { + let directory = url.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(snapshot) + let temporary = directory.appendingPathComponent(".snapshot-\(UUID().uuidString).tmp") + try data.write(to: temporary, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) } + try fileManager.moveItem(at: temporary, to: url) + Self.reloadTimelines() + } + + public func writeIfChanged(_ snapshot: WidgetSnapshot) { + lock.lock() + let previous = lastWritten + if previous?.withoutGeneratedAt == snapshot.withoutGeneratedAt { + lock.unlock() + return + } + do { + try write(snapshot) + lastWritten = snapshot + lock.unlock() + } catch { + let key = String(describing: type(of: error)) + if loggedFailures.insert(key).inserted { logger.error("Widget snapshot write failed: \(key, privacy: .public)") } + lock.unlock() + } + } + + public static func reloadTimelines() { + #if canImport(WidgetKit) + if #available(macOS 14, *) { WidgetCenter.shared.reloadAllTimelines() } + #endif + } +} + +private extension WidgetSnapshot { + var withoutGeneratedAt: WidgetSnapshot { + WidgetSnapshot( + schemaVersion: schemaVersion, generatedAt: 0, state: state, stateTitle: stateTitle, + detail: detail, endpointDisplay: endpointDisplay, menuTitle: menuTitle, today: today, + quotas: quotas, chart: chart, lastUpdated: lastUpdated + ) + } +} diff --git a/app/Sources/MenuBarCoreTests/ActionSuite.swift b/app/Sources/MenuBarCoreTests/ActionSuite.swift new file mode 100644 index 00000000000..8f11cc350f7 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/ActionSuite.swift @@ -0,0 +1,356 @@ +import Foundation +import MenuBarCore + +/// Write-action behaviour, especially the timing: `/api/stop` answers before it drains, +/// so "returned 200" and "actually stopped" are different facts. +enum ActionSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = Box() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class Box: @unchecked Sendable { var value: T? } + private struct NoCredentials: CredentialStore { func loadAPIKey() -> String? { nil } } + + /// A clock the test drives, so the timeout path runs in milliseconds. + private final class FakeClock: @unchecked Sendable { + private let lock = NSLock() + private var current = Date(timeIntervalSince1970: 1_784_915_000) + func now() -> Date { lock.lock(); defer { lock.unlock() }; return current } + func advance(_ seconds: TimeInterval) { + lock.lock(); current = current.addingTimeInterval(seconds); lock.unlock() + } + } + + private static func makeCoordinator(clock: FakeClock = FakeClock()) -> ActionCoordinator { + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + // Skip the real wall-clock wait, but advance the clock by the same amount so the + // deadline still expires. + return ActionCoordinator( + client: client, + sleeper: { seconds in clock.advance(seconds) }, + now: { clock.now() } + ) + } + + private static func paths() -> [String] { + StubProtocol.recorded.compactMap { $0.url?.path } + } + + static func run(_ t: TestRunner) { + // The proxy stops the launchd service on purpose, so a successful stop is + // reported as "you will have to start it again", not as a plain success. + t.test("stop: reports manual-start once the port stops answering") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), // POST /api/stop + .init(status: 0, body: "", urlError: .cannotConnectToHost), // probe: gone + ]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx service start") } + t.equal(outcome, .requiresManualStart("ocx service start")) + t.expect(paths().first == "/api/stop", "stop called first, got \(paths())") + } + + // A 200 that never drains must not be reported as success. + t.test("stop: a proxy that keeps answering is a failure, not a success") { + // The stub falls back to "connection refused" once its queue drains, which + // would look like a successful stop. Queue well past the poll count so the + // timeout path is what actually runs. + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: #"{"port":10100}"#, urlError: nil), + count: 400 + )) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("still responding"), "expected a timeout message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("stop: an unreachable proxy fails without claiming it stopped anything") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) + } + + t.test("stop: a failure message never carries the response body") { + StubProtocol.reset([.init(status: 500, body: "SECRET-CONFIG", urlError: nil)]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(!message.contains("SECRET"), "leaked body: \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: disabling sends exactly one PATCH and succeeds") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("anthropic", disabled: true, defaultProvider: "openai") + } + t.equal(outcome, .succeeded) + t.equal(StubProtocol.recorded.count, 1) + t.equal(StubProtocol.recorded.first?.httpMethod, "PATCH") + let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" + t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") + } + + // The proxy answers 400 for this, so the request is never sent at all. + t.test("provider: the default provider is refused before any request") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("openai", disabled: true, defaultProvider: "openai") + } + if case .failed(let message) = outcome { + t.expect(message.contains("default provider"), "expected an explanation, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + t.equal(StubProtocol.recorded.count, 0, "no request should be sent") + } + + t.test("provider: enabling the default provider is allowed") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("openai", disabled: false, defaultProvider: "openai") + } + t.equal(outcome, .succeeded) + } + + t.test("provider: a 400 from the proxy surfaces without quoting its body") { + StubProtocol.reset([.init(status: 400, body: "cannot disable the default provider", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("x", disabled: true, defaultProvider: "openai") + } + if case .failed(let message) = outcome { + t.expect(!message.contains("cannot disable"), "leaked body: \(message)") + t.expect(message.contains("refused"), "expected a refusal message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: an unreachable proxy fails cleanly") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let outcome = sync { + await makeCoordinator().setProvider("x", disabled: false, defaultProvider: nil) + } + t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) + } + // Was tautological: it built its own non-empty literals and then asserted they + // were non-empty. Now drives real failures and checks the message the user sees. + t.test("actions: every real failure path produces a usable message") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let unreachable = sync { await makeCoordinator().setProvider("x", disabled: false, defaultProvider: nil) } + + StubProtocol.reset([.init(status: 400, body: "raw body", urlError: nil)]) + let rejected = sync { await makeCoordinator().setProvider("x", disabled: true, defaultProvider: "openai") } + + let guarded = sync { await makeCoordinator().setProvider("openai", disabled: true, defaultProvider: "openai") } + + for outcome in [unreachable, rejected, guarded] { + guard case .failed(let message) = outcome else { + t.expect(false, "expected .failed, got \(outcome)") + continue + } + t.expect(!message.isEmpty, "empty failure message") + t.expect(message.hasSuffix(".") || message.hasSuffix("!"), + "message should read as a sentence: \(message)") + t.expect(!message.contains("raw body"), "leaked body: \(message)") + } + } + + // The stop response carries success:false when restoreNativeCodex() failed + // (src/server/management-api.ts:145-147). The proxy still shuts down, but native + // Codex is left pointing at a port that is closing. + t.test("stop: a restore failure is reported, not swallowed as success") { + StubProtocol.reset([ + .init(status: 200, body: #"{"success":false,"message":"restore failed: /some/path"}"#, urlError: nil), + .init(status: 0, body: "", urlError: .cannotConnectToHost), + ]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + t.equal(outcome, .stoppedWithRestoreFailure("ocx start")) + } + + t.test("stop: a success:true body reports the ordinary manual-start outcome") { + StubProtocol.reset([ + .init(status: 200, body: #"{"success":true,"message":"ok"}"#, urlError: nil), + .init(status: 0, body: "", urlError: .cannotConnectToHost), + ]) + t.equal(sync { await makeCoordinator().stop(startCommand: "ocx start") }, + .requiresManualStart("ocx start")) + } + + // Only a refused connection proves the proxy is gone. A 500 or an undecodable + // 200 means an HTTP server is still listening. + t.test("stop: a 500 during polling is not mistaken for a stopped proxy") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 500, body: "", urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("still responding"), "expected a timeout, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("stop: an undecodable 200 during polling still counts as reachable") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: "not json", urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed = outcome { + t.expect(true, "timed out rather than claiming success") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: a second write while one is in flight is refused, not raced") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), + .init(status: 200, body: "{}", urlError: nil), + ]) + let coordinator = makeCoordinator() + let outcomes: [ActionOutcome] = sync { + async let first = coordinator.setProvider("x", disabled: true, defaultProvider: nil) + async let second = coordinator.setProvider("x", disabled: false, defaultProvider: nil) + return await [first, second] + } + let refused = outcomes.filter { if case .failed = $0 { return true }; return false } + t.equal(refused.count, 1, "exactly one of the two concurrent writes is refused") + } + + t.test("provider: writes to different providers are not blocked by each other") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), + .init(status: 200, body: "{}", urlError: nil), + ]) + let coordinator = makeCoordinator() + let outcomes: [ActionOutcome] = sync { + async let a = coordinator.setProvider("a", disabled: true, defaultProvider: nil) + async let b = coordinator.setProvider("b", disabled: true, defaultProvider: nil) + return await [a, b] + } + t.equal(outcomes, [.succeeded, .succeeded]) + } + + // The distinction that matters: only a refused connection proves the proxy is + // gone. Collapsing timeouts into "unreachable" is what made a stop report as + // confirmed while the proxy was still running. + t.test("liveness: only a refused connection reads as gone") { + let cases: [(URLError.Code, ProxyClient.Liveness, String)] = [ + (.cannotConnectToHost, .refused, "connection refused"), + (.timedOut, .indeterminate, "timeout"), + (.networkConnectionLost, .indeterminate, "socket dropped"), + (.cannotFindHost, .indeterminate, "host lookup"), + (.notConnectedToInternet, .indeterminate, "no network"), + ] + for (code, expected, label) in cases { + StubProtocol.reset([.init(status: 0, body: "", urlError: code)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + t.equal(sync { await client.liveness() }, expected, label) + } + } + + t.test("liveness: any HTTP answer proves the port is occupied") { + for status in [200, 401, 403, 500] { + let body = status == 200 ? #"{"port":10100}"# : "" + StubProtocol.reset([ + .init(status: status, body: body, urlError: nil), + .init(status: status, body: body, urlError: nil), + ]) + let client = ProxyClient(endpoint: .default, session: makeSession(), + credentials: StubCredentialsFixed(key: "k")) + t.equal(sync { await client.liveness() }, .reachable, "status \(status)") + } + } + + t.test("liveness: an undecodable 200 is reachable, not gone") { + StubProtocol.reset([.init(status: 200, body: "not json at all", urlError: nil)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + t.equal(sync { await client.liveness() }, .reachable) + } + + // A timeout must not end the stop as a confirmed success. + t.test("stop: a timeout during polling never confirms the stop") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 0, body: "", urlError: .timedOut), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("could not be confirmed"), + "expected an inconclusive message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + // A 401 already answers "is anything listening". Retrying it through the normal + // credential path spent a second full timeout and could downgrade a + // known-reachable result to indeterminate if the retry failed. + t.test("liveness: a 401 answers immediately without a credential retry") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 0, body: "", urlError: .timedOut), // must never be used + ]) + let client = ProxyClient(endpoint: .default, session: makeSession(), + credentials: StubCredentialsFixed(key: "stored-key")) + t.equal(sync { await client.liveness() }, .reachable) + t.equal(StubProtocol.recorded.count, 1, "liveness must be a single attempt") + } + + t.test("liveness: the probe honours a caller-supplied timeout") { + StubProtocol.reset([.init(status: 200, body: #"{"port":10100}"#, urlError: nil)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + _ = sync { await client.liveness(timeout: 0.25) } + t.equal(StubProtocol.recorded.first?.timeoutInterval, 0.25) + } + + // The final probe must not overrun the stop deadline by its own timeout. + t.test("stop: the last probe is capped to the remaining deadline") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: #"{"port":10100}"#, urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + _ = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + + // Every liveness probe after the POST must request no more than 1.5s, and + // the last must be clamped to whatever remained. + let probes = StubProtocol.recorded.dropFirst() + t.expect(!probes.isEmpty, "expected liveness probes") + for probe in probes { + t.expect(probe.timeoutInterval <= 1.5, + "probe timeout \(probe.timeoutInterval) exceeds the cap") + } + } + } + + private struct StubCredentialsFixed: CredentialStore { + let key: String? + func loadAPIKey() -> String? { key } + } +} diff --git a/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift new file mode 100644 index 00000000000..bed663b8e2d --- /dev/null +++ b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift @@ -0,0 +1,27 @@ +import Foundation +import MenuBarCore + +enum CompanionSettingsSuite { + static func run(_ t: TestRunner) { + let decoder = JSONDecoder() + t.test("companion settings: empty JSON uses defaults") { + let settings = try decoder.decode(CompanionSettings.self, from: Data("{}".utf8)) + t.equal(settings, .defaults) + } + t.test("companion settings: unknown enum uses its default") { + let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"future","chartStyle":"future","tokenMetric":"future","aggregation":"future","chartGrouping":"future"}"#.utf8)) + t.equal(settings.menuBarMetric, .tokens) + t.equal(settings.chartStyle, .line) + t.equal(settings.tokenMetric, .total) + t.equal(settings.aggregation, .sum) + t.equal(settings.chartGrouping, .model) + } + t.test("companion settings: full payload decodes") { + let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"quota","menuBarTemplate":"{requests}","showToday":false,"showChart":false,"showModels":false,"showCost":false,"showAccounts":false,"chartHours":72,"bucketMinutes":180,"chartStyle":"stackedBar","tokenMetric":"cached","aggregation":"max","chartGrouping":"modelAccount","models":["openai/gpt"],"hiddenProviders":["openai"]}"#.utf8)) + t.equal(settings.chartHours, 72) + t.equal(settings.chartStyle, .stackedBar) + t.equal(settings.models, ["openai/gpt"]) + t.equal(settings.hiddenProviders, ["openai"]) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/DiscoverySuite.swift b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift new file mode 100644 index 00000000000..e8aa2acf1bd --- /dev/null +++ b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift @@ -0,0 +1,82 @@ +import Foundation +import MenuBarCore + +enum DiscoverySuite { + static func run(_ t: TestRunner) { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ocx-discovery-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + func writeRecord(_ contents: String) throws { + try contents.write( + to: root.appendingPathComponent("runtime-port.json"), + atomically: true, + encoding: .utf8 + ) + } + + t.test("discovery: honours a valid record") { + try writeRecord(#"{"pid": 14582, "port": 10100}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 10100) + } + + t.test("discovery: honours a non-default port") { + try writeRecord(#"{"pid": 1, "port": 18080}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 18080) + } + + t.test("discovery: a record without pid still resolves") { + try writeRecord(#"{"port": 10250}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 10250) + } + + t.test("discovery: malformed JSON falls back to the default port") { + try writeRecord("{not json at all") + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, ProxyDiscovery.defaultPort) + } + + t.test("discovery: out-of-range ports fall back to the default") { + for invalid in ["0", "70000", "-1"] { + try writeRecord(#"{"port": \#(invalid)}"#) + t.equal( + ProxyDiscovery.resolve(configDirectory: root).port, + ProxyDiscovery.defaultPort, + "port \(invalid)" + ) + } + } + + t.test("discovery: a missing file falls back to the default port") { + let empty = root.appendingPathComponent("empty-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + t.equal(ProxyDiscovery.resolve(configDirectory: empty).port, ProxyDiscovery.defaultPort) + } + + // The record may carry a hostname, but the app must never follow it: the port + // file is a convenience, not a redirection mechanism. + t.test("discovery: host stays loopback even when the record names another host") { + try writeRecord(#"{"pid": 1, "port": 10100, "hostname": "10.0.0.5"}"#) + let endpoint = ProxyDiscovery.resolve(configDirectory: root) + t.equal(endpoint.host, "127.0.0.1") + t.equal(endpoint.baseURL.absoluteString, "http://127.0.0.1:10100") + } + + t.test("discovery: OPENCODEX_HOME overrides the default directory") { + let resolved = ProxyDiscovery.configDirectory( + environment: ["OPENCODEX_HOME": root.path], + home: URL(fileURLWithPath: "/nonexistent") + ) + t.equal(resolved.path, root.path) + } + + t.test("discovery: a blank OPENCODEX_HOME falls back to the home directory") { + let home = URL(fileURLWithPath: "/Users/example") + let resolved = ProxyDiscovery.configDirectory( + environment: ["OPENCODEX_HOME": " "], + home: home + ) + t.equal(resolved.path, home.appendingPathComponent(".opencodex").path) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/FormattingSuite.swift b/app/Sources/MenuBarCoreTests/FormattingSuite.swift new file mode 100644 index 00000000000..00b816cdeb9 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/FormattingSuite.swift @@ -0,0 +1,77 @@ +import Foundation +import MenuBarCore + +/// Magnitudes are taken from the live proxy capture in 002_api_surface.md. +enum FormattingSuite { + static func run(_ t: TestRunner) { + t.test("format: counts group below 10k and suffix above") { + t.equal(Format.count(0), "0") + t.equal(Format.count(1_746), "1,746") + t.equal(Format.count(9_999), "9,999") + t.equal(Format.count(232_507), "233K") + t.equal(Format.count(1_200_000), "1.20M") + } + + // Rounding can push a value across its own unit boundary: 999_999 scales to + // 999.999K and must promote to 1.00M rather than render "1000K". + t.test("format: values promote at suffix rollover boundaries") { + t.equal(Format.count(999_999), "1.00M") + t.equal(Format.count(999_499), "999K") + t.equal(Format.tokens(999_999_999), "1B") + t.equal(Format.tokens(999_999_999_999), "1T") + t.equal(Format.cost(999_999), "$1.00M") + } + + t.test("format: exact unit thresholds render as the new unit") { + t.equal(Format.tokens(1_000), "1K") + t.equal(Format.tokens(1_000_000), "1M") + t.equal(Format.tokens(1_000_000_000), "1B") + } + + t.test("format: tokens are suffixed at scale") { + t.equal(Format.tokens(999), "999") + t.equal(Format.tokens(12_400_000), "12M") + t.equal(Format.tokens(36_536_664_705), "37B") + } + + t.test("format: cost switches to a suffix above one thousand") { + t.equal(Format.cost(8.21), "$8.21") + t.equal(Format.cost(999.99), "$999.99") + t.equal(Format.cost(34_018.25204647066), "$34.0K") + } + + // Unknown and zero are different facts. Rendering nil as "0" is the fake-data + // tell that 003 section 6 bans. + t.test("format: nil renders an em dash while zero renders zero") { + t.equal(Format.count(nil), "—") + t.equal(Format.tokens(nil), "—") + t.equal(Format.cost(nil), "—") + t.equal(Format.percent(nil), "—") + t.equal(Format.count(0), "0") + t.equal(Format.cost(0), "$0.00") + } + + t.test("format: percent rounds") { + t.equal(Format.percent(44), "44%") + t.equal(Format.percent(86.82666666666667), "87%") + t.equal(Format.percent(9.976811594202898), "10%") + } + + t.test("format: reset countdowns are coarse") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + t.equal(Format.resetsIn(now.addingTimeInterval(60 * 30), now: now), "30m") + t.equal(Format.resetsIn(now.addingTimeInterval(3600 * 5), now: now), "5h") + t.equal(Format.resetsIn(now.addingTimeInterval(86_400 * 3 + 3600 * 4), now: now), "3d 4h") + t.equal(Format.resetsIn(now.addingTimeInterval(-60), now: now), "expired") + t.equal(Format.resetsIn(nil), "—") + } + + t.test("format: staleness ages read naturally") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + t.equal(Format.age(now.addingTimeInterval(-10), now: now), "just now") + t.equal(Format.age(now.addingTimeInterval(-120), now: now), "2m ago") + t.equal(Format.age(now.addingTimeInterval(-7200), now: now), "2h ago") + t.equal(Format.age(nil), "—") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/Harness.swift b/app/Sources/MenuBarCoreTests/Harness.swift new file mode 100644 index 00000000000..0deb1d6ae46 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/Harness.swift @@ -0,0 +1,105 @@ +import Foundation + +/// A dependency-free assertion harness. +/// +/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line +/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles +/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to +/// run the unit tests of a menu bar companion would put the tests out of reach for most +/// contributors and for any CI runner without Xcode selected. +/// +/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that +/// both a human and CI can read. If the package ever gains a full-Xcode requirement for +/// other reasons, migrating these cases to swift-testing is mechanical. +public struct TestFailure { + let test: String + let message: String + let file: String + let line: Int +} + +public final class TestRunner { + private(set) var passed = 0 + private(set) var failures: [TestFailure] = [] + private var current = "" + + public init() {} + + public func test(_ name: String, _ body: () throws -> Void) { + current = name + let failuresBefore = failures.count + do { + try body() + } catch { + failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) + print("FAIL — \(name): threw \(error)") + return + } + // A case that recorded an expectation failure is not a pass, even though its + // body returned normally. + if failures.count == failuresBefore { + passed += 1 + print("ok — \(name)") + } + } + + public func expect( + _ condition: Bool, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + guard !condition else { return } + let failure = TestFailure(test: current, message: message(), file: file, line: line) + failures.append(failure) + print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") + } + + public func equal( + _ actual: T, + _ expected: T, + _ label: String = "", + file: String = #file, + line: Int = #line + ) { + expect( + actual == expected, + "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", + file: file, + line: line + ) + } + + public func notNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) -> T? { + expect(value != nil, "\(label) should not be nil", file: file, line: line) + return value + } + + public func isNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) { + expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) + } + + /// Prints the summary and returns the process exit code. + public func summarize() -> Int32 { + print("") + if failures.isEmpty { + print("\(passed) passed, 0 failed") + return 0 + } + print("\(passed) passed, \(failures.count) FAILED") + for failure in failures { + print(" - \(failure.test): \(failure.message)") + } + return 1 + } +} diff --git a/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift new file mode 100644 index 00000000000..286502ac91c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift @@ -0,0 +1,36 @@ +import Foundation +import MenuBarCore + +enum MenuBarTitleSuite { + private static let reportJSON = #"{"range":"today","summary":{"requests":12,"totalTokens":3456,"inputTokens":1000,"outputTokens":2000,"estimatedCostUsd":1.25}}"# + + static func run(_ t: TestRunner) { + let report = try! JSONDecoder().decode(UsageReport.self, from: Data(reportJSON.utf8)) + for metric in [CompanionSettings.MenuBarMetric.requests, .tokens, .cost] { + t.test("menu title: \(metric.rawValue) metric") { + let settings = CompanionSettings(menuBarMetric: metric) + t.expect(MenuBarTitle.render(settings: settings, today: report, quotas: []) != nil, "title") + } + } + t.test("menu title: quota picks the lowest percent") { + let settings = CompanionSettings(menuBarMetric: .quota) + let quotas = try! JSONDecoder().decode([QuotaReport].self, from: Data(#"[{"provider":"a","quota":{"weeklyPercent":80}},{"provider":"b","quota":{"weeklyPercent":20}}]"#.utf8)).map { $0.normalized() } + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: quotas), "20%") + } + t.test("menu title: template replaces placeholders") { + let settings = CompanionSettings(menuBarTemplate: "{requests}/{totalTokens}/{costUsd}") + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: []), "12/3K/$1.25") + } + t.test("menu title: none is nil and unknowns are em dashes") { + t.isNil(MenuBarTitle.render(settings: CompanionSettings(menuBarMetric: .none), today: report, quotas: []), "none") + let settings = CompanionSettings(menuBarTemplate: "{inputTokens}") + t.equal(MenuBarTitle.render(settings: settings, today: nil, quotas: []), "—") + } + t.test("menu title: long output is truncated") { + let settings = CompanionSettings(menuBarTemplate: "012345678901234567890123456789") + let title = MenuBarTitle.render(settings: settings, today: report, quotas: []) + t.equal(title?.count, 24) + t.expect(title?.hasSuffix("…") == true, "ellipsis") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift new file mode 100644 index 00000000000..0ec0012e621 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -0,0 +1,258 @@ +import Foundation +import MenuBarCore + +/// Fixtures are verbatim captures from the live proxy on 2026-07-25, recorded in +/// devlog/_plan/260725_macos_menubar_app/002_api_surface.md. Hand-written fixtures would +/// only prove the models decode themselves. +enum ModelDecodingSuite { + private static func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) + } + + private struct Envelope: Decodable { let reports: [QuotaReport]? } + + private static let liveHealth = """ + {"routingKind":"opencodex-local","autostartEnabled":false,"serviceInstalled":true, + "serviceViable":true,"serviceEnabled":true,"serviceRunning":true,"serviceStale":false, + "serviceConflict":false,"serviceSupported":true,"shimInstalled":false, + "shimHealthy":false,"platform":"darwin","diagnosticStale":true,"routingInjected":true, + "localRoutingDependency":true,"status":"at-risk","rebootSafe":false,"protection":"none", + "shimCoverage":"none","recommendedCommand":"ocx service install", + "commands":{"installService":"ocx service install","installShim":"ocx codex-shim install", + "restoreNative":"ocx restore"}} + """ + + private static let liveQuotas = """ + {"generatedAt":1784915336899,"reports":[ + {"provider":"openai","label":"OpenAI (Codex login)","source":"chatgpt:wham", + "quota":{"updatedAt":1784915090763,"weeklyPercent":44,"weeklyResetAt":1785258443, + "resetCredits":3}}, + {"provider":"anthropic","label":"Anthropic Claude","source":"anthropic:oauth-usage", + "quota":{"weeklyPercent":58,"weeklyResetAt":1785265199718, + "customWindows":[{"label":"5h","percent":1,"resetAt":1784928599718}]}}, + {"provider":"xai","label":"xAI Grok","source":"xai:grok-billing", + "quota":{"monthlyPercent":86.82666666666667,"monthlyResetAt":1785542400000}}]} + """ + + static func run(_ t: TestRunner) { + t.test("health: decodes the live startup-health payload") { + let health = try decode(StartupHealth.self, liveHealth) + t.equal(health.status, "at-risk") + t.equal(health.platform, "darwin") + t.equal(health.recommendedCommand, "ocx service install") + t.equal(health.isProtected, false) + t.equal(health.isServiceManaged, true) + t.equal(health.manualStartCommand, "ocx service start") + } + + t.test("health: an unknown status string decodes without throwing") { + let health = try decode(StartupHealth.self, #"{"status":"some-future-state"}"#) + t.equal(health.status, "some-future-state") + t.equal(health.isProtected, false) + } + + t.test("health: without service fields it is not service-managed") { + let health = try decode(StartupHealth.self, #"{"status":"protected"}"#) + t.equal(health.isProtected, true) + t.equal(health.isServiceManaged, false) + t.equal(health.manualStartCommand, "ocx start") + } + + // The live /api/settings key set contains no defaultProvider. Decoding must + // succeed anyway — an earlier plan draft expected the field here and was wrong. + t.test("settings: decodes without a defaultProvider field") { + let json = """ + {"codexAutoStart":false,"port":10100,"hostname":"127.0.0.1","streamMode":"auto", + "startupHealth":{"status":"protected"},"codexRuntime":{}} + """ + let settings = try decode(ProxySettings.self, json) + t.equal(settings.port, 10100) + t.equal(settings.hostname, "127.0.0.1") + t.equal(settings.streamMode, "auto") + } + + t.test("config: supplies defaultProvider") { + let json = """ + {"port":10100,"hostname":"127.0.0.1","defaultProvider":"openai", + "codexAutoStart":false,"websockets":{},"providers":{}} + """ + t.equal(try decode(ProxyConfigSummary.self, json).defaultProvider, "openai") + } + + t.test("usage: decodes the live summary at real magnitudes") { + let json = """ + {"range":"30d","surface":"all","since":1782323333603,"generatedAt":1784915333603, + "summary":{"requests":232507,"measuredRequests":225380,"estimatedRequests":14618, + "inputTokens":33521662469,"outputTokens":127401110,"totalTokens":36536664705, + "coverageRatio":0.969347159440361,"estimatedCostUsd":34018.25204647066}, + "days":[{"date":"2026-06-28","requests":1746,"totalTokens":0,"models":[]}]} + """ + let report = try decode(UsageReport.self, json) + t.equal(report.summary?.requests, 232_507) + t.equal(report.summary?.totalTokens, 36_536_664_705) + t.equal(report.effectiveRange, .thirtyDays) + t.equal(report.rangeLabel, "LAST 30 DAYS") + t.equal(report.summary?.hasEstimates, true) + t.equal(report.isEmpty, false) + } + + // The server silently degrades an unrecognized range to 30d, so the label must + // follow the response and never the request. + t.test("usage: an unknown range degrades to a neutral label") { + let report = try decode(UsageReport.self, #"{"range":"24h"}"#) + t.isNil(report.effectiveRange, "effectiveRange for 24h") + t.equal(report.rangeLabel, "USAGE") + } + + t.test("usage: zero requests reads as empty") { + let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"requests":0}}"#) + t.equal(report.isEmpty, true) + t.equal(report.isEmptyOrUnknown, true) + } + + // Unknown and zero are different facts: an omitted count must not render as + // "No requests". + t.test("usage: an omitted request count is unknown, not empty") { + let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"totalTokens":5}}"#) + t.isNil(report.isEmptyOrUnknown, "isEmptyOrUnknown for an omitted count") + t.equal(report.isEmpty, false, "isEmpty must not claim empty for unknown") + } + + t.test("usage: the range enum is closed") { + t.isNil(UsageRange(rawValue: "24h"), "UsageRange(24h)") + t.equal(UsageRange.allCases.map(\.rawValue), ["today", "7d", "30d", "all"]) + } + + // The decisive trap: openai sends weeklyResetAt in SECONDS (1785258443) while + // anthropic sends MILLISECONDS (1785265199718) in the same array. + t.test("quotas: mixed second and millisecond timestamps both resolve to 2026") { + let reports = try decode(Envelope.self, liveQuotas).reports ?? [] + t.equal(reports.count, 3) + let calendar = Calendar(identifier: .gregorian) + for report in reports { + let normalized = report.normalized() + guard let date = t.notNil(normalized.resetAt, "\(report.provider) resetAt") else { continue } + t.equal(calendar.component(.year, from: date), 2026, "\(report.provider) year") + } + } + + t.test("quotas: normalization picks the right window per provider") { + let reports = try decode(Envelope.self, liveQuotas).reports ?? [] + let byProvider = Dictionary(uniqueKeysWithValues: reports.map { ($0.provider, $0.normalized()) }) + t.equal(byProvider["openai"]?.windowLabel, "week") + t.equal(byProvider["openai"]?.percent, 44) + t.equal(byProvider["anthropic"]?.windowLabel, "week") + t.equal(byProvider["xai"]?.windowLabel, "month") + t.equal(byProvider["xai"]?.providerLabel, "xAI Grok") + } + + t.test("quotas: a custom-window-only quota uses its own label") { + let json = """ + {"provider":"p","quota":{"customWindows":[{"label":"5h","percent":12,"resetAt":1784928599718}]}} + """ + let normalized = try decode(QuotaReport.self, json).normalized() + t.equal(normalized.windowLabel, "5h") + t.equal(normalized.percent, 12) + } + + // Live kimi reports weeklyPercent AND fiveHourPercent; live cursor and + // google-antigravity each carry two customWindows. Returning one window would + // hide real quota pressure. + t.test("quotas: kimi exposes both its five-hour and weekly windows") { + let json = """ + {"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":22, + "fiveHourResetAt":1784928599718,"weeklyPercent":61,"weeklyResetAt":1785265199718}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows.count, 2) + t.equal(windows.map(\.windowLabel), ["5h", "week"]) + // The compact row prefers the longer horizon. + t.equal(report.normalized().windowLabel, "week") + t.equal(report.normalized().percent, 61) + } + + t.test("quotas: multiple custom windows are all retained") { + let json = """ + {"provider":"cursor","label":"Cursor","quota":{"monthlyPercent":10, + "monthlyResetAt":1785256304000, + "customWindows":[{"label":"First-party models","percent":4,"resetAt":1785256304000}, + {"label":"API usage","percent":1,"resetAt":1785256304000}]}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows.count, 3) + t.equal(windows.map(\.windowLabel), ["month", "First-party models", "API usage"]) + t.equal(report.normalized().windowLabel, "month") + } + + t.test("quotas: a provider with only custom windows still normalizes") { + let json = """ + {"provider":"google-antigravity","label":"Google","quota":{ + "customWindows":[{"label":"Gem","percent":30,"resetAt":1785256304000}, + {"label":"Cla","percent":12,"resetAt":1785256304000}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalizedWindows().count, 2) + t.equal(report.normalized().windowLabel, "Gem") + t.equal(report.normalized().percent, 30) + } + + // Every window can stop work. A provider at 99% of a five-hour limit is blocked + // right now even if its monthly usage is 10%; picking the longer horizon would + // paint that row green while the user cannot make a request. + t.test("quotas: the compact row shows the window under the most pressure") { + let json = """ + {"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":99, + "fiveHourResetAt":1784928599718,"monthlyPercent":10,"monthlyResetAt":1785542400000}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().windowLabel, "5h") + t.equal(report.normalized().percent, 99) + t.equal(report.normalizedWindows().count, 2) + } + + t.test("quotas: equal pressure breaks toward the longer horizon") { + let json = """ + {"provider":"p","quota":{"fiveHourPercent":50,"fiveHourResetAt":1784928599718, + "weeklyPercent":50,"weeklyResetAt":1785265199718}} + """ + t.equal(try decode(QuotaReport.self, json).normalized().windowLabel, "week") + } + + t.test("quotas: a window reporting only a reset time does not outrank a measured one") { + let json = """ + {"provider":"p","quota":{"weeklyPercent":12,"weeklyResetAt":1785265199718, + "customWindows":[{"label":"unmeasured","resetAt":1785265199718}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().windowLabel, "week") + t.equal(report.normalized().percent, 12) + } + + t.test("quotas: an absent quota normalizes to a nil percent") { + let normalized = try decode(QuotaReport.self, #"{"provider":"p","label":"P"}"#).normalized() + t.isNil(normalized.percent, "percent") + t.equal(normalized.hasPercent, false) + t.isNil(normalized.resetAt, "resetAt") + } + + t.test("providers: decodes the live list") { + let json = """ + [{"name":"openai","adapter":"openai-responses","hasApiKey":false, + "authMode":"forward","disabled":false,"codexAccountMode":"pool"}, + {"name":"anthropic","adapter":"anthropic","hasApiKey":false, + "authMode":"oauth","disabled":true}] + """ + let providers = try decode([ProviderSummary].self, json) + t.equal(providers.count, 2) + t.equal(providers[0].name, "openai") + t.equal(providers[0].isEnabled, true) + t.equal(providers[1].isEnabled, false) + } + + t.test("providers: a provider without a disabled field is enabled") { + t.equal(try decode(ProviderSummary.self, #"{"name":"custom"}"#).isEnabled, true) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift new file mode 100644 index 00000000000..838249be4c0 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -0,0 +1,408 @@ +import Foundation +import MenuBarCore + +/// Exercises the polling contract against a stubbed transport instead of asserting that +/// four constants still hold the values they were declared with. +enum PollingSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = Box() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class Box: @unchecked Sendable { var value: T? } + + /// Polls the coordinator's own waiter count, so registration is observed rather + /// than assumed from elapsed time. + private static func waitForWaiter(_ coordinator: PollingCoordinator, timeout: TimeInterval = 5) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await coordinator.waiterCount > 0 { return true } + try? await Task.sleep(nanoseconds: 5_000_000) + } + return false + } + + private final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + var value: Bool { lock.lock(); defer { lock.unlock() }; return flag } + func set() { lock.lock(); flag = true; lock.unlock() } + } + + private static let healthOK = #"{"status":"protected","serviceInstalled":true,"serviceEnabled":true}"# + private static let usageOK = #"{"range":"7d","summary":{"requests":10},"days":[{"date":"d","requests":10}]}"# + private static let quotasOK = #"{"reports":[{"provider":"p","quota":{"weeklyPercent":5}}]}"# + private static let providersOK = #"[{"name":"openai"}]"# + private static let configOK = #"{"defaultProvider":"openai"}"# + + private static func paths() -> [String] { + StubProtocol.recorded.compactMap { $0.url?.path } + } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + func makeCoordinator() -> PollingCoordinator { + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: NoCredentials()) + return PollingCoordinator(client: client, endpoint: endpoint) + } + + // The whole point of gating: a closed popover must not trigger aggregation. + t.test("polling: a closed popover fetches only liveness") { + StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) + let coordinator = makeCoordinator() + sync { await coordinator.refresh() } + t.equal(paths(), ["/api/startup-health", "/api/companion/settings", "/api/usage", "/api/usage/timeline"]) + } + + t.test("polling: opening the popover fetches on-open and aggregation reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + return await coordinator.current + } + t.expect(paths().contains("/api/providers"), "providers fetched on open") + t.expect(paths().contains("/api/usage"), "usage fetched on open") + t.expect(paths().contains("/api/provider-quotas"), "quotas fetched on open") + t.equal(snapshot.providersLoaded, true) + t.equal(snapshot.quotasLoaded, true) + t.equal(snapshot.defaultProvider, "openai") + } + + // Reopening within the aggregation window should refresh cheap reads only. + t.test("polling: a second open reuses aggregation but refreshes on-open reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.setPopoverOpen(false) + await coordinator.setPopoverOpen(true) + } + let usageCalls = paths().filter { $0 == "/api/usage" }.count + let providerCalls = paths().filter { $0 == "/api/providers" }.count + t.equal(usageCalls, 1, "aggregation respects its interval") + t.equal(providerCalls, 2, "on-open reads run every open") + } + + t.test("polling: a refused proxy becomes unreachable and counts a failure") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.state, .unreachable) + t.equal(snapshot.consecutiveFailures, 1) + t.equal(snapshot.showsData, false) + } + + t.test("polling: repeated failures widen the interval to the backoff value") { + StubProtocol.reset(Array(repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 4)) + let coordinator = makeCoordinator() + let interval = sync { () -> TimeInterval in + for _ in 0..<3 { await coordinator.refresh() } + return await coordinator.currentInterval + } + t.equal(interval, PollingCoordinator.backoffInterval) + } + + t.test("polling: a recovered proxy resets the failure count and interval") { + StubProtocol.reset([ + .init(status: 0, body: "", urlError: .cannotConnectToHost), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.consecutiveFailures, 0) + t.equal(snapshot.state.isRunning, true) + } + + // A degraded proxy keeps its last-known numbers with an explicit age, rather + // than blanking the panel. + t.test("polling: a 500 degrades while retaining previously loaded data") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 500, body: "", urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + await coordinator.refresh() + return await coordinator.current + } + if case .degraded = snapshot.state { + t.expect(true, "degraded") + } else { + t.expect(false, "expected degraded, got \(snapshot.state)") + } + t.equal(snapshot.showsData, true, "stale-but-labelled beats blank") + _ = t.notNil(snapshot.usage, "usage retained") + } + + t.test("polling: the recommended command is carried into the snapshot") { + StubProtocol.reset([ + .init(status: 200, + body: #"{"status":"at-risk","recommendedCommand":"ocx service install"}"#, + urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.recommendedCommand, "ocx service install") + } + + t.test("polling: observers receive the snapshot on registration and on change") { + StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) + let coordinator = makeCoordinator() + let counter = Counter() + sync { + await coordinator.observe { _ in counter.bump() } + await coordinator.refresh() + } + t.expect(counter.count >= 2, "expected at least 2 notifications, got \(counter.count)") + } + + // On-open reads are cheap but not free: running them on every liveness tick + // turned two rarely-changing endpoints into 5-second pollers. + t.test("polling: a background tick while open does not refetch on-open reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.refresh() // ordinary liveness tick + } + t.equal(paths().filter { $0 == "/api/providers" }.count, 1, "providers fetched once") + t.equal(paths().filter { $0 == "/api/config" }.count, 1, "config fetched once") + t.equal(paths().filter { $0 == "/api/startup-health" }.count, 2, "health fetched twice") + } + + t.test("polling: a closed popover skips on-open reads entirely") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.refresh() + await coordinator.refresh() + } + t.equal(paths().filter { $0 == "/api/providers" }.count, 0) + t.equal(paths().filter { $0 == "/api/usage" }.count, 1) + } + + // A failing quota endpoint must not drag its healthy sibling into the 5s tick. + t.test("polling: a partial aggregation failure still consumes the interval") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 500, body: "", urlError: nil), // quotas fail + .init(status: 200, body: healthOK, urlError: nil), // next tick + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.refresh() + } + t.equal(paths().filter { $0 == "/api/usage" }.count, 1, "usage not refetched after a sibling failure") + } + + t.test("polling: degraded without any loaded data does not claim to show data") { + StubProtocol.reset([.init(status: 500, body: "", urlError: nil)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.showsData, false, "no data was ever loaded") + t.isNil(snapshot.dataAge, "dataAge") + } + + // refresh() coalesces, so a caller that needs authoritative state afterwards + // must wait for the cycle that absorbed its request — not just for its own + // immediate return. + t.test("polling: refreshAndWait returns only after a cycle has published") { + // setPopoverOpen already runs a full cycle, so queue enough for both it and + // the refreshAndWait that follows; the stub falls back to connection-refused + // once drained, which would look like a stopped proxy. + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + await coordinator.refreshAndWait() + return await coordinator.current + } + // If it returned early the health read would not have landed yet. + t.equal(snapshot.state.isRunning, true) + _ = t.notNil(snapshot.lastUpdated, "lastUpdated after refreshAndWait") + } + + // The first two refreshAndWait tests ran with refreshInFlight == false, so they + // never entered waitForCompletion() at all. These hold a cycle suspended in the + // stub so the coalescing path is the one under test. + t.test("polling: refreshAndWait suspends behind an in-flight cycle and resumes") { + StubProtocol.reset(Array( + repeating: .init(status: 200, body: healthOK, urlError: nil), count: 20)) + let gate = DispatchSemaphore(value: 0) + StubProtocol.setGate(gate) + defer { + StubProtocol.setGate(nil) + for _ in 0..<40 { gate.signal() } + } + + let coordinator = makeCoordinator() + let returned = Flag() + + let first = Task { await coordinator.refresh() } + // Wait for the request to actually reach the gate rather than guessing. + t.equal(StubProtocol.gateEntered.wait(timeout: .now() + 5), .success, + "cycle 1 should reach the gate") + + let waiter = Task { + await coordinator.refreshAndWait() + returned.set() + } + // Wait for the waiter to actually REGISTER, rather than sleeping and hoping + // it was scheduled. A fixed sleep let this test pass without ever entering + // the continuation path. + t.equal(sync { await waitForWaiter(coordinator) }, true, "waiter should register") + t.equal(returned.value, false, "refreshAndWait must not return while a cycle is in flight") + + StubProtocol.setGate(nil) + for _ in 0..<40 { gate.signal() } + sync { _ = await first.value; _ = await waiter.value } + t.equal(returned.value, true, "refreshAndWait must resume once the queued cycle publishes") + t.equal(sync { await coordinator.waiterCount }, 0, "no waiter should remain registered") + } + + // The queued cycle must FAIL here. Two contract details drive the setup: + // drainPendingRefresh only runs while the popover is OPEN, and an open cycle + // consumes health + providers + config + usage + quotas. So the popover is + // opened first (consuming its own cycle), then one gated 200 lets cycle 1 reach + // the gate, and every response after that is a refusal. An earlier version + // queued three 200s with the popover closed and silently re-tested the success + // path — which is exactly what the new state assertion caught. + t.test("polling: a waiter is released when the queued cycle fails") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { await coordinator.setPopoverOpen(true) } + + var responses: [StubProtocol.Response] = [.init(status: 200, body: healthOK, urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 30)) + StubProtocol.reset(responses) + let gate = DispatchSemaphore(value: 0) + StubProtocol.setGate(gate) + defer { + StubProtocol.setGate(nil) + for _ in 0..<60 { gate.signal() } + } + + let returned = Flag() + let first = Task { await coordinator.refresh() } + t.equal(StubProtocol.gateEntered.wait(timeout: .now() + 5), .success, + "cycle 1 should reach the gate") + + let waiter = Task { + await coordinator.refreshAndWait() + returned.set() + } + t.equal(sync { await waitForWaiter(coordinator) }, true, "waiter should register") + t.equal(returned.value, false, "must still be suspended") + + StubProtocol.setGate(nil) + for _ in 0..<60 { gate.signal() } + let snapshot = sync { () -> ProxySnapshot in + _ = await first.value + _ = await waiter.value + return await coordinator.current + } + t.equal(returned.value, true, "a failing queued cycle must still release its waiter") + // Proves the refusal was actually consumed, not a second 200. + t.equal(snapshot.state, .unreachable, "the queued cycle must have failed") + } + + t.test("polling: refreshAndWait survives a failing cycle without hanging") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refreshAndWait() + return await coordinator.current + } + t.equal(snapshot.state, .unreachable) + } + } + + private struct NoCredentials: CredentialStore { + func loadAPIKey() -> String? { nil } + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + var count: Int { lock.lock(); defer { lock.unlock() }; return value } + func bump() { lock.lock(); value += 1; lock.unlock() } + } +} diff --git a/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift new file mode 100644 index 00000000000..b9cb5d8146c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift @@ -0,0 +1,106 @@ +import Foundation +import MenuBarCore + +enum SnapshotStateSuite { + private static func health(_ status: String?, service: Bool = false) -> StartupHealth { + StartupHealth( + status: status, + protection: service ? "service" : "none", + serviceInstalled: service, + serviceEnabled: service + ) + } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + t.test("state: every state has a word, so colour is never the only signal") { + let states: [ProxyState] = [ + .loading, .running(health("protected")), .unreachable, + .unauthorized, .degraded("boom"), + ] + for state in states { + t.expect(!state.title.isEmpty, "state \(state) must have a title") + } + t.equal(ProxyState.unreachable.title, "Stopped") + t.equal(ProxyState.unauthorized.title, "Needs API key") + } + + t.test("state: an unprotected but running proxy reads as a warning, not healthy") { + t.equal(ProxyState.running(health("protected")).tone, .good) + t.equal(ProxyState.running(health("at-risk")).tone, .warning) + t.equal(ProxyState.unreachable.tone, .bad) + } + + // loading is the one state with nothing to act on; every other non-running + // state must name a next step rather than dead-ending the user. + t.test("actions: loading has none, and every other non-running state names one") { + let loading = ProxySnapshot(state: .loading, endpoint: endpoint) + t.equal(loading.nextAction, NextAction.none) + + let unauthorized = ProxySnapshot(state: .unauthorized, endpoint: endpoint) + t.equal(unauthorized.nextAction, NextAction.addAPIKey) + + let degraded = ProxySnapshot(state: .degraded("x"), endpoint: endpoint) + t.equal(degraded.nextAction, NextAction.retry) + } + + t.test("actions: a stopped proxy offers the start command for its own install") { + let plain = ProxySnapshot(state: .unreachable, endpoint: endpoint) + t.equal(plain.nextAction, NextAction.runCommand("ocx start")) + + let managed = ProxySnapshot( + state: .unreachable, endpoint: endpoint, + lastKnownStartCommand: "ocx service start" + ) + t.equal(managed.nextAction, NextAction.runCommand("ocx service start")) + } + + t.test("state: the running detail line drops empty and 'none' qualifiers") { + let protectedDetail = ProxyState.running(health("protected", service: true)).detail + t.equal(protectedDetail, "protected · service") + // protection "none" is noise, not information. + t.equal(ProxyState.running(health("at-risk")).detail, "at-risk") + } + + t.test("snapshot: quota rows normalize one row per provider") { + let json = """ + [{"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":99, + "fiveHourResetAt":1784928599718,"monthlyPercent":10,"monthlyResetAt":1785542400000}}] + """ + let quotas = try JSONDecoder().decode([QuotaReport].self, from: Data(json.utf8)) + let snapshot = ProxySnapshot(state: .running(health("protected")), endpoint: endpoint, quotas: quotas) + t.equal(snapshot.quotaRows.count, 1) + t.equal(snapshot.quotaRows[0].windowLabel, "5h") + } + + t.test("snapshot: the default provider cannot be toggled") { + let providers = try JSONDecoder().decode( + [ProviderSummary].self, + from: Data(#"[{"name":"openai"},{"name":"anthropic"}]"#.utf8) + ) + let snapshot = ProxySnapshot( + state: .running(health("protected")), endpoint: endpoint, + providers: providers, defaultProvider: "openai" + ) + t.equal(snapshot.canToggle(providers[0]), false, "default provider") + t.equal(snapshot.canToggle(providers[1]), true, "non-default provider") + } + + t.test("snapshot: an omitted usage count stays unknown rather than empty") { + let usage = try JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"7d","summary":{"totalTokens":5}}"#.utf8) + ) + let snapshot = ProxySnapshot(state: .running(health("protected")), endpoint: endpoint, usage: usage) + t.isNil(snapshot.usageIsEmpty, "usageIsEmpty for an omitted count") + } + + t.test("polling: the interval backs off only after repeated failures") { + t.equal(PollingCoordinator.livenessInterval, 5) + t.equal(PollingCoordinator.heavyInterval, 60) + t.equal(PollingCoordinator.backoffInterval, 30) + t.equal(PollingCoordinator.backoffAfterFailures, 3) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift b/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift new file mode 100644 index 00000000000..eb2b2b5ca05 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift @@ -0,0 +1,15 @@ +import Foundation +import MenuBarCore + +enum TimelineDecodingSuite { + static func run(_ t: TestRunner) { + t.test("timeline: decodes series and derived maxima") { + let json = #"{"start":0,"end":3600,"bucketSeconds":1800,"buckets":2,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"a","provider":"p","model":"m","total":3,"points":[1,2]},{"id":"b","provider":"p","model":"n","total":4,"points":[4,0]}],"availableModels":["p/m","p/n"],"missingMeasurements":1,"truncated":true}"# + let timeline = try JSONDecoder().decode(UsageTimeline.self, from: Data(json.utf8)) + t.equal(timeline.maxPoint, 4) + t.equal(timeline.stackedMax, 5) + t.equal(timeline.isEmpty, false) + t.equal(timeline.truncated, true) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift new file mode 100644 index 00000000000..570e10bdeab --- /dev/null +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -0,0 +1,362 @@ +import Foundation +import MenuBarCore + +/// Stubs the network so status mapping, the 401 retry, cancellation, request shape, and +/// body privacy are covered without a live proxy. +final class StubProtocol: URLProtocol, @unchecked Sendable { + struct Response { + var status: Int + var body: String + var urlError: URLError.Code? + } + + nonisolated(unsafe) static var queue: [Response] = [] + nonisolated(unsafe) static var recorded: [URLRequest] = [] + private static let lock = NSLock() + + static func reset(_ responses: [Response]) { + lock.lock(); defer { lock.unlock() } + queue = responses + recorded = [] + bodies = [] + gateStorage = nil + } + + nonisolated(unsafe) static var bodies: [Data] = [] + /// When set, `startLoading` blocks until the gate is opened. Lets a test hold a + /// refresh suspended so the coalescing/continuation path is genuinely exercised. + /// + /// Access goes through `setGate`/`currentGate` under the same lock as the rest of + /// the stub state: an unsynchronised read here is a data race, and `gateEntered` + /// lets a test wait for the request to actually reach the gate instead of inferring + /// it from elapsed time. + nonisolated(unsafe) private static var gateStorage: DispatchSemaphore? + static let gateEntered = DispatchSemaphore(value: 0) + + static func setGate(_ gate: DispatchSemaphore?) { + lock.lock(); gateStorage = gate; lock.unlock() + } + + static func currentGate() -> DispatchSemaphore? { + lock.lock(); defer { lock.unlock() } + return gateStorage + } + + static func record(_ request: URLRequest) { + lock.lock(); defer { lock.unlock() } + recorded.append(request) + // URLProtocol replaces httpBody with a stream, so read it here or the body is + // unobservable — which let an "exact body" assertion pass with no body at all. + if let body = request.httpBody { + bodies.append(body) + } else if let stream = request.httpBodyStream { + stream.open() + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: buffer.count) + if read <= 0 { break } + data.append(buffer, count: read) + } + stream.close() + bodies.append(data) + } + } + + static func next() -> Response? { + lock.lock(); defer { lock.unlock() } + return queue.isEmpty ? nil : queue.removeFirst() + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.record(request) + // Held open by tests that need a request to stay in flight. + if let gate = Self.currentGate() { + Self.gateEntered.signal() + gate.wait() + } + if request.url?.path == "/api/companion/settings" { + let body = #"{"settings":{"menuBarMetric":"requests","showToday":true,"showChart":true,"showModels":true,"showCost":true,"showAccounts":true,"chartHours":24,"bucketMinutes":60,"chartStyle":"line","tokenMetric":"total","aggregation":"sum","chartGrouping":"model","hiddenProviders":[]}}"# + let http = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + return + } + if request.url?.path == "/api/usage/timeline" { + let body = #"{"start":0,"end":3600,"bucketSeconds":3600,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"# + let http = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + return + } + guard let response = Self.next() else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) + return + } + if let code = response.urlError { + client?.urlProtocol(self, didFailWithError: URLError(code)) + return + } + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: nil + )! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(response.body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +private struct StubCredentials: CredentialStore { + let key: String? + let counter: Counter + + final class Counter: @unchecked Sendable { + private(set) var loads = 0 + private let lock = NSLock() + func bump() { lock.lock(); loads += 1; lock.unlock() } + } + + func loadAPIKey() -> String? { + counter.bump() + return key + } +} + +enum TransportSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = ResultBox() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class ResultBox: @unchecked Sendable { var value: T? } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + t.test("transport: a 200 decodes into the model") { + StubProtocol.reset([.init(status: 200, body: #"{"status":"protected"}"#, urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let result: String? = sync { + try? await client.health().status + } + t.equal(result, "protected") + } + + t.test("transport: a 500 maps to .http and never carries the body") { + StubProtocol.reset([.init(status: 500, body: "SECRET-CONFIG-VALUE", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .http(500)) + let message = error?.userMessage ?? "" + t.expect(!message.contains("SECRET"), "error message must not echo the body: \(message)") + } + + t.test("transport: malformed JSON maps to .decoding") { + StubProtocol.reset([.init(status: 200, body: "{not json", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .decoding) + } + + t.test("transport: connection refused maps to .unreachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unreachable) + } + + // A policy failure is not evidence the proxy is down; conflating them would put + // the UI in "Stopped" for a running proxy. + t.test("transport: an unrelated URLError maps to .transport, not .unreachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .appTransportSecurityRequiresSecureConnection)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .transport) + } + + t.test("transport: cancellation propagates instead of reading as a stopped proxy") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cancelled)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let wasCancellation: Bool = sync { + do { _ = try await client.health(); return false } + catch is CancellationError { return true } + catch { return false } + } + t.equal(wasCancellation, true) + } + + t.test("auth: a 401 with a stored key retries once and succeeds") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + ]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "test-key", counter: counter)) + let status: String? = sync { try? await client.health().status } + t.equal(status, "protected") + t.equal(counter.loads, 1, "credential loaded exactly once") + t.equal(StubProtocol.recorded.count, 2, "one retry") + let retry = StubProtocol.recorded.last + t.equal(retry?.value(forHTTPHeaderField: "x-opencodex-api-key"), "test-key") + } + + t.test("auth: a 401 with no stored key surfaces .unauthorized without retrying") { + StubProtocol.reset([.init(status: 401, body: "", urlError: nil)]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: counter)) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unauthorized) + t.equal(StubProtocol.recorded.count, 1, "no retry without a key") + } + + // A stale stored key must not spin: one retry, then surface the failure. + t.test("auth: repeated 401s retry exactly once, never looping") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + ]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "stale", counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unauthorized) + t.equal(StubProtocol.recorded.count, 2, "exactly one retry") + } + + t.test("requests: usage sends the enum range as a query item") { + StubProtocol.reset([.init(status: 200, body: #"{"range":"7d"}"#, urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + _ = sync { try? await client.usage(range: .sevenDays) } + let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" + t.expect(url.contains("range=7d"), "expected range=7d in \(url)") + t.expect(url.contains("/api/usage"), "expected /api/usage in \(url)") + t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexMenuBar/dev") + } + + t.test("requests: the provider patch sends exactly {\"disabled\":true}") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + _ = sync { + try? await client.setProviderDisabled("anthropic", disabled: true) + } + let request = StubProtocol.recorded.first + t.equal(request?.httpMethod, "PATCH") + let url = request?.url?.absoluteString ?? "" + t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") + + // Assert on the ACTUAL request body. An earlier version encoded its own + // dictionary and compared that, so it would have passed with no body at all. + guard let body = StubProtocol.bodies.first else { + t.expect(false, "no request body captured") + return + } + let decoded = try JSONSerialization.jsonObject(with: body) as? [String: Any] + t.equal(decoded?.keys.sorted() ?? [], ["disabled"], "body must carry only 'disabled'") + t.equal(decoded?["disabled"] as? Bool, true) + } + + t.test("liveness: a 401 still proves something is listening") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + ]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "k", counter: .init())) + t.equal(sync { await client.isReachable() }, true) + } + + t.test("liveness: connection refused reads as not reachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + t.equal(sync { await client.isReachable() }, false) + } + + t.test("endpoint: an out-of-range port cannot be constructed") { + t.isNil(ProxyEndpoint(port: 0), "port 0") + t.isNil(ProxyEndpoint(port: -1), "port -1") + t.isNil(ProxyEndpoint(port: 70_000), "port 70000") + t.equal(ProxyEndpoint(port: 10_100)?.baseURL.absoluteString, "http://127.0.0.1:10100") + } + + // The actor suspends across each request, so several calls can be in flight and + // all receive 401. A single global "already tried" flag made the second caller + // fail even though the first had just loaded a usable key. + t.test("auth: concurrent initial 401s both succeed once a key is loaded") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + ]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "test-key", counter: counter)) + + let outcomes: [String] = sync { + async let first = try? await client.health().status + async let second = try? await client.health().status + let results = await [first, second] + return results.map { $0 ?? "error" } + } + + t.equal(outcomes.filter { $0 == "protected" }.count, 2, "both calls should succeed") + t.equal(counter.loads, 1, "credentials loaded exactly once") + t.equal(StubProtocol.recorded.count, 4, "two initial calls plus two retries") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift b/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift new file mode 100644 index 00000000000..66f25176b68 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift @@ -0,0 +1,37 @@ +import Foundation +import MenuBarCore + +enum WidgetSnapshotSuite { + static func run(_ t: TestRunner) { + t.test("widget snapshot: maps today and caps chart series") { + var series: [String] = [] + for index in 0..<7 { + series.append(#"{"id":"s\#(index)","provider":"p","model":"m\#(index)","total":1,"points":[1]}"#) + } + let timelineJSON = #"{"start":1,"end":2,"bucketSeconds":60,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[\#(series.joined(separator: ","))],"availableModels":[],"missingMeasurements":0}"# + let timeline = try! JSONDecoder().decode(UsageTimeline.self, from: Data(timelineJSON.utf8)) + let report = try! JSONDecoder().decode(UsageReport.self, from: Data(#"{"range":"today","summary":{"requests":2,"totalTokens":3,"estimatedCostUsd":4}}"#.utf8)) + var snapshot = ProxySnapshot(endpoint: .default, usage: report, today: report, timeline: timeline) + snapshot.state = .running(try! JSONDecoder().decode(StartupHealth.self, from: Data(#"{"status":"protected"}"#.utf8))) + let widget = WidgetSnapshot.make(from: snapshot, now: Date(timeIntervalSince1970: 100)) + t.equal(widget.schemaVersion, 1) + t.equal(widget.today?.requests, 2) + t.equal(widget.chart?.series.count, 6) + } + t.test("widget snapshot: encoded payload contains no credentials") { + let snapshot = WidgetSnapshot.make(from: ProxySnapshot(endpoint: .default), now: Date()) + let data = try! JSONEncoder().encode(snapshot) + let text = String(decoding: data, as: UTF8.self) + t.expect(!text.contains("apiKey") && !text.contains("x-opencodex"), "privacy") + } + t.test("widget snapshot: store writes to injected home") { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = WidgetSnapshotStore(homeDirectory: home) + let snapshot = WidgetSnapshot.make(from: ProxySnapshot(endpoint: .default), now: Date()) + store.writeIfChanged(snapshot) + t.expect(FileManager.default.fileExists(atPath: store.url.path), "snapshot file") + let mode = (try? FileManager.default.attributesOfItem(atPath: store.url.path)[.posixPermissions] as? NSNumber)?.intValue + t.equal(mode, 0o600) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift new file mode 100644 index 00000000000..41928f17a1c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -0,0 +1,20 @@ +import Foundation + +// Entry point for `swift run --package-path app MenuBarCoreTests`. +// See Harness.swift for why this is an executable rather than an XCTest bundle. + +let runner = TestRunner() + +DiscoverySuite.run(runner) +ModelDecodingSuite.run(runner) +FormattingSuite.run(runner) +CompanionSettingsSuite.run(runner) +TimelineDecodingSuite.run(runner) +MenuBarTitleSuite.run(runner) +WidgetSnapshotSuite.run(runner) +TransportSuite.run(runner) +SnapshotStateSuite.run(runner) +PollingSuite.run(runner) +ActionSuite.run(runner) + +exit(runner.summarize()) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift new file mode 100644 index 00000000000..7ca5c1d8e29 --- /dev/null +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -0,0 +1,254 @@ +import AppKit +import MenuBarCore + +public final class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem? + /// A key-capable panel rather than `NSPopover`. + /// + /// This is the single most-tested decision in this file. `NSPopover` from an + /// accessory (`LSUIElement`) process creates a window that never appears in + /// `NSApp.windows` and reports `canBecomeKey == false`, so macOS will not route key + /// events to it no matter how the process is activated — Escape and the Tab path + /// simply never arrive. A `nonactivatingPanel` that overrides `canBecomeKey` + /// measures as `canBecomeKey=1 isKey=1` under the same conditions. + private let panel = PopoverPanel() + private let controller = PopoverViewController() + private var coordinator: PollingCoordinator? + private var actions: ActionCoordinator? + private var client: ProxyClient? + private let widgetStore = WidgetSnapshotStore() + /// The snapshot the UI is currently showing, for decisions that need context + /// (the start command to display, the default provider to protect). + private var latest: ProxySnapshot? + private var endpoint = ProxyEndpoint.default + private var pollTask: Task? + /// Fallback Escape handling for the case where the panel is visible but another + /// process holds focus. Installed on open, removed on close. + private var escapeMonitor: Any? + + public override init() { super.init() } + + public func applicationDidFinishLaunching(_ notification: Notification) { + endpoint = ProxyDiscovery.resolve() + let client = ProxyClient(endpoint: endpoint) + self.client = client + let coordinator = PollingCoordinator(client: client, endpoint: endpoint) + self.coordinator = coordinator + self.actions = ActionCoordinator(client: client) + + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.image = StatusIcon.image(for: .loading) + item.button?.imagePosition = .imageOnly + item.button?.target = self + item.button?.action = #selector(togglePopover) + item.button?.setAccessibilityLabel("OpenCodex proxy status") + statusItem = item + + controller.onDashboard = { [weak self] in self?.openDashboard() } + controller.onCompanionSettings = { [weak self] in self?.openCompanionSettings() } + controller.onStop = { [weak self] in self?.stopProxy() } + controller.onRefresh = { [weak self] in self?.refreshNow() } + controller.onAddKey = { [weak self] in self?.openDashboard() } + controller.onRetry = { [weak self] in self?.refreshNow() } + controller.onToggleProvider = { [weak self] name, disable in + self?.toggleProvider(name, disable: disable) + } + controller.onQuit = { NSApp.terminate(nil) } + + panel.contentViewController = controller + panel.onDismiss = { [weak self] in self?.handlePanelClosed() } + + // The observer closure is `@Sendable` and crosses actor boundaries, so it must + // not capture the delegate. It hops to the main actor and looks the delegate up + // there instead. + Task { + await coordinator.observe { snapshot in + Task { @MainActor in + (NSApp.delegate as? AppDelegate)?.render(snapshot) + } + } + await MainActor.run { (NSApp.delegate as? AppDelegate)?.startPolling() } + } + } + + public func applicationWillTerminate(_ notification: Notification) { + pollTask?.cancel() + removeEscapeMonitor() + panel.dismiss() + } + + // MARK: - Polling + + @MainActor + fileprivate func startPolling() { + guard let coordinator else { return } + pollTask?.cancel() + pollTask = Task { + while !Task.isCancelled { + await coordinator.refresh() + let interval = await coordinator.currentInterval + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + private func refreshNow() { + Task { [coordinator] in await coordinator?.refresh(includeHeavy: true) } + } + + @MainActor + fileprivate func render(_ snapshot: ProxySnapshot) { + latest = snapshot + let title = snapshot.menuBarTitle ?? "" + statusItem?.button?.title = title + statusItem?.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium) + statusItem?.button?.imagePosition = title.isEmpty ? .imageOnly : .imageLeading + statusItem?.button?.image = StatusIcon.image(for: snapshot.state) + statusItem?.button?.toolTip = "OpenCodex — \(snapshot.state.title) (\(snapshot.endpoint.display))" + controller.apply(snapshot) + let widgetSnapshot = WidgetSnapshot.make(from: snapshot) + Task.detached { [widgetStore] in widgetStore.writeIfChanged(widgetSnapshot) } + } + + // MARK: - Actions + + #if DEBUG + /// Testing hook: drives the exact presentation path a status-item click uses, so a + /// harness can verify key focus and Escape without Accessibility permission. + /// Debug-only — it is not part of the shipped surface. + public func debugTogglePanel() { togglePopover() } + #endif + + @objc private func togglePopover() { + guard let button = statusItem?.button else { return } + if panel.isShown { + panel.dismiss() + } else { + panel.present(from: button) + installEscapeMonitor() + Task { [coordinator] in await coordinator?.setPopoverOpen(true) } + } + } + + /// Called by the panel whenever it closes, however it was dismissed. + private func handlePanelClosed() { + removeEscapeMonitor() + Task { [coordinator] in await coordinator?.setPopoverOpen(false) } + } + + /// The panel is key-capable, so `cancelOperation(_:)` handles Escape in the normal + /// case. This local monitor is belt-and-braces for the window where the panel is up + /// but focus sits elsewhere in this process, such as the confirmation sheet. + private func installEscapeMonitor() { + removeEscapeMonitor() + escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + // While a confirmation is up, Escape belongs to the alert: consuming it + // here dismissed the panel and stranded the alert with no way to cancel. + guard event.keyCode == 53, + self?.panel.isShown == true, + self?.panel.isPresentingModal == false + else { return event } + self?.panel.dismiss() + return nil + } + } + + + private func removeEscapeMonitor() { + if let monitor = escapeMonitor { NSEvent.removeMonitor(monitor) } + escapeMonitor = nil + } + + private func openDashboard() { + NSWorkspace.shared.open(endpoint.baseURL) + } + + private func openCompanionSettings() { + guard let url = URL(string: "\(endpoint.baseURL.absoluteString)/#/usage#usage-section-companion") else { return } + NSWorkspace.shared.open(url) + } + + /// Stopping is destructive: it interrupts in-flight requests and stops the launchd + /// service, so nothing restarts the proxy. It always confirms first. + private func stopProxy() { + let alert = NSAlert() + alert.messageText = "Stop the OpenCodex proxy?" + alert.informativeText = + "In-flight requests will be interrupted, and OpenCodex will not restart on its own." + alert.alertStyle = .warning + alert.addButton(withTitle: "Stop proxy") + alert.addButton(withTitle: "Cancel") + + // The alert takes key focus, which would otherwise trip resignKey and dismiss + // the panel behind it — leaving a user who chose Cancel with nothing. + panel.isPresentingModal = true + NSApp.activate(ignoringOtherApps: true) + let confirmed = alert.runModal() == .alertFirstButtonReturn + panel.isPresentingModal = false + + guard confirmed else { + panel.makeKeyAndOrderFront(nil) + return + } + + let startCommand = latest?.lastKnownStartCommand ?? "ocx start" + controller.showResult("Stopping…", isError: false) + + Task { [actions, coordinator] in + let outcome = await actions?.stop(startCommand: startCommand) ?? .failed("Unavailable.") + await coordinator?.refresh() + await MainActor.run { [weak self] in + switch outcome { + case .succeeded: + self?.controller.showResult("Proxy stopped.", isError: false) + case .requiresManualStart(let command): + // Not a failure — the API has no start endpoint by design. + self?.controller.showResult("Proxy stopped. Start it again with \(command)", isError: false) + case .stoppedWithRestoreFailure(let command): + // The proxy is down but native Codex still points at the dead port. + self?.controller.showResult( + "Proxy stopped, but restoring native Codex failed. Run `ocx restore`, then \(command)", + isError: true + ) + case .failed(let message): + self?.controller.showResult(message, isError: true) + } + } + } + } + + /// Optimistic toggle: the switch has already moved, so a rejection must move it back + /// rather than leave the UI showing a state the proxy refused. + private func toggleProvider(_ name: String, disable: Bool) { + let defaultProvider = latest?.defaultProvider + controller.setProviderBusy(name, true, intended: !disable) + + Task { [actions, coordinator] in + let outcome = await actions?.setProvider(name, disabled: disable, defaultProvider: defaultProvider) + ?? .failed("Unavailable.") + await MainActor.run { [weak self] in + switch outcome { + case .succeeded: + self?.controller.showResult( + disable ? "\(name) disabled." : "\(name) enabled.", + isError: false + ) + case .failed(let message): + self?.controller.revertProvider(name, to: !disable) + self?.controller.showResult(message, isError: true) + case .requiresManualStart, .stoppedWithRestoreFailure: + // Not reachable for a provider write. + break + } + } + // Re-read so the summary line and switch states match the proxy, not our + // optimistic guess. refreshAndWait rather than refresh: a coalesced refresh + // returns immediately, which would re-enable the switch against pre-write + // data. + await coordinator?.refreshAndWait() + await MainActor.run { [weak self] in + self?.controller.setProviderBusy(name, false) + } + } + } +} diff --git a/app/Sources/MenuBarUI/CompanionViews.swift b/app/Sources/MenuBarUI/CompanionViews.swift new file mode 100644 index 00000000000..a23c4bf42c9 --- /dev/null +++ b/app/Sources/MenuBarUI/CompanionViews.swift @@ -0,0 +1,78 @@ +import AppKit +import MenuBarCore + +final class ModelsListView: NSView { + private let stack = NSStackView() + private let caption = makeLabel("MODELS", font: Theme.micro, color: Theme.faint) + + init() { + super.init(frame: .zero) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = Theme.tightGap + stack.addArrangedSubview(caption) + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + clearRows() + let rows = snapshot.todayRows.sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) }.prefix(5) + isHidden = !snapshot.settings.showModels || rows.isEmpty + for row in rows { + let model = [row.provider, row.model].compactMap { $0 }.joined(separator: "/") + let cost = snapshot.settings.showCost ? " · \(Format.cost(row.estimatedCostUsd))" : "" + stack.addArrangedSubview(makeLabel( + "\(model) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))\(cost)", + font: Theme.caption, color: Theme.text + )) + } + } + + private func clearRows() { + for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } + } +} + +final class AccountsListView: NSView { + private let stack = NSStackView() + private let caption = makeLabel("ACCOUNTS", font: Theme.micro, color: Theme.faint) + + init() { + super.init(frame: .zero) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = Theme.tightGap + stack.addArrangedSubview(caption) + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + clearRows() + let rows = (snapshot.today?.accounts ?? []).sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) } + isHidden = !snapshot.settings.showAccounts || rows.isEmpty + for row in rows { + stack.addArrangedSubview(makeLabel( + "\(row.accountLogLabel ?? Format.unknown) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))", + font: Theme.caption, color: Theme.text + )) + } + } + + private func clearRows() { + for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } + } +} diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift new file mode 100644 index 00000000000..5c579f88be9 --- /dev/null +++ b/app/Sources/MenuBarUI/PopoverPanel.swift @@ -0,0 +1,170 @@ +import AppKit + +/// The popover surface. +/// +/// Deliberately a panel rather than `NSPopover`. Measured on macOS 27 from an accessory +/// (`LSUIElement`) process: the window `NSPopover` creates never appears in +/// `NSApp.windows` and reports `canBecomeKey == false`, so the OS refuses to route key +/// events to it — Escape and Tab never arrive regardless of how the process is +/// activated. The same probe against this panel reports `canBecomeKey=1 isKey=1`. +/// +/// `nonactivatingPanel` keeps the click-through feel of a menu bar popover: opening it +/// does not steal focus from the user's editor. +public final class PopoverPanel: NSPanel { + /// Invoked whenever the panel closes, however it was dismissed. + public var onDismiss: (() -> Void)? + + private var clickOutsideMonitor: Any? + + public init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 300), + styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], + backing: .buffered, + defer: false + ) + isFloatingPanel = true + level = .statusBar + hidesOnDeactivate = false + becomesKeyOnlyIfNeeded = false + isOpaque = false + backgroundColor = .clear + hasShadow = true + isMovable = false + animationBehavior = .utilityWindow + } + + /// Wraps the content in a real popover material. + /// + /// A borderless panel has NO background of its own: without this the dashboard + /// composites straight onto whatever application is underneath, so labels collide + /// with the app behind it and contrast depends on that app's colours. `NSPopover` + /// supplies this surface automatically; a panel must build it. + public override var contentViewController: NSViewController? { + didSet { + guard let content = contentViewController?.view else { return } + contentView = PopoverSurface.make(content: content) + } + } + + /// Suspends resign-key dismissal, so presenting a modal sheet does not tear the + /// panel down behind it and strand a user who chose Cancel. + public var isPresentingModal = false + + public override var canBecomeKey: Bool { true } + /// Never main: this is chrome, not a document window. + public override var canBecomeMain: Bool { false } + + public var isShown: Bool { isVisible } + + /// Presents under a status item button, clamped to the visible screen. + public func present(from button: NSStatusBarButton) { + guard let buttonWindow = button.window else { return } + layoutContent() + + let size = contentViewController?.preferredContentSize ?? frame.size + setContentSize(size) + + let buttonRect = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil)) + var origin = NSPoint( + x: buttonRect.midX - size.width / 2, + y: buttonRect.minY - size.height - 6 + ) + + if let screen = buttonWindow.screen ?? NSScreen.main { + let visible = screen.visibleFrame + origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - size.width - 8) + origin.y = max(origin.y, visible.minY + 8) + } + + setFrameOrigin(origin) + makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + installClickOutsideMonitor() + } + + public func dismiss() { + // Idempotent: a late monitor callback must not re-run teardown. + guard isVisible else { return } + removeClickOutsideMonitor() + orderOut(nil) + onDismiss?() + } + + /// Transient behaviour: clicking anywhere else dismisses, matching what a menu bar + /// popover trained the user to expect. + private func installClickOutsideMonitor() { + removeClickOutsideMonitor() + clickOutsideMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + self?.dismiss() + } + } + + private func removeClickOutsideMonitor() { + if let monitor = clickOutsideMonitor { NSEvent.removeMonitor(monitor) } + clickOutsideMonitor = nil + } + + public override func cancelOperation(_ sender: Any?) { dismiss() } + + public override func resignKey() { + super.resignKey() + // Losing key focus means the user moved on — unless we put the focus elsewhere + // ourselves by presenting a confirmation. + guard !isPresentingModal else { return } + if isVisible { dismiss() } + } + + private func layoutContent() { + contentViewController?.view.layoutSubtreeIfNeeded() + } +} + +private enum PopoverSurface { + static func make(content: NSView) -> NSView { + let surface: NSView +#if compiler(>=6.2) + if #available(macOS 26, *) { + let glass = NSGlassEffectView() + glass.cornerRadius = 16 + glass.style = .regular + glass.contentView = content + surface = glass + } else { + surface = makeMaterialSurface(content: content) + } +#else + surface = makeMaterialSurface(content: content) +#endif + + let host = NSView() + host.addSubview(surface) + surface.translatesAutoresizingMaskIntoConstraints = false + content.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + surface.topAnchor.constraint(equalTo: host.topAnchor), + surface.leadingAnchor.constraint(equalTo: host.leadingAnchor), + surface.trailingAnchor.constraint(equalTo: host.trailingAnchor), + surface.bottomAnchor.constraint(equalTo: host.bottomAnchor), + content.topAnchor.constraint(equalTo: surface.topAnchor), + content.leadingAnchor.constraint(equalTo: surface.leadingAnchor), + content.trailingAnchor.constraint(equalTo: surface.trailingAnchor), + content.bottomAnchor.constraint(equalTo: surface.bottomAnchor), + ]) + return host + } + + private static func makeMaterialSurface(content: NSView) -> NSView { + let effect = NSVisualEffectView() + effect.material = .popover + effect.blendingMode = .behindWindow + effect.state = .active + effect.wantsLayer = true + effect.layer?.cornerRadius = 10 + effect.layer?.masksToBounds = true + effect.addSubview(content) + return effect + } +} diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift new file mode 100644 index 00000000000..1cc8dd51ae4 --- /dev/null +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -0,0 +1,384 @@ +import AppKit +import MenuBarCore + +/// The popover body: one column ordered by urgency. +/// +/// Deliberately not a tab bar. A menu bar popover is a glance surface, and tabs would put +/// the answer to "is it fine?" one click away three times out of four. +/// +/// Fixed header and action row with a scrolling middle: the quota and provider sections +/// grow with the user's configuration, and an uncapped popover would eventually run off +/// the screen. +public final class PopoverViewController: NSViewController { + public override init(nibName: NSNib.Name?, bundle: Bundle?) { + super.init(nibName: nibName, bundle: bundle) + } + + public required init?(coder: NSCoder) { nil } + + /// The popover never grows past this; the variable middle scrolls instead. + private static let maxHeight: CGFloat = 480 + + // Fixed chrome + private let header = StatusHeaderView() + private let dashboardButton = NSButton() + private let stopButton = NSButton() + private let overflowButton = NSButton() + /// State-specific call to action: "Add key…" or "Retry". + private let primaryButton = NSButton() + + // Scrolling body + private let scrollView = NSScrollView() + private let body = NSStackView() + private let metrics = MetricsView() + private let timelineChart = TimelineChartView() + private let models = ModelsListView() + private let accounts = AccountsListView() + private let quotaStack = NSStackView() + private let quotaEmpty = makeLabel("No provider quota sources connected.", font: Theme.caption, color: Theme.muted) + private let providers = ProviderListView() + /// Transient result of the last write action. Actions that report nothing leave the + /// user guessing whether anything happened. + private let resultBanner = makeLabel("", font: Theme.caption, color: Theme.muted) + private let skeleton = SkeletonView() + private let guidanceLabel: NSTextField = { + let field = makeLabel("", font: Theme.caption, color: Theme.muted) + // Guidance is a sentence, not a stat: let it wrap instead of truncating away + // the half that explains what to do. + field.lineBreakMode = .byWordWrapping + field.maximumNumberOfLines = 3 + field.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 + return field + }() + private let commandField = NSTextField(labelWithString: "") + private let metricsSeparator = makeSeparator() + private let quotaSeparator = makeSeparator() + + public var onDashboard: (() -> Void)? + public var onCompanionSettings: (() -> Void)? + public var onStop: (() -> Void)? + public var onQuit: (() -> Void)? + public var onRefresh: (() -> Void)? + /// `(provider, shouldDisable)`. + public var onToggleProvider: ((String, Bool) -> Void)? + /// Distinct callbacks: "Retry" must retry in place, while "Add key…" navigates to + /// the dashboard. Routing both through one handler made Retry open a browser. + public var onAddKey: (() -> Void)? + public var onRetry: (() -> Void)? + + private var snapshot: ProxySnapshot? + private var scrollHeight: NSLayoutConstraint? + /// Guards the banner's auto-hide so a newer result is not cleared by an older timer. + private var resultToken = 0 + + public override func loadView() { + configureControls() + resultBanner.isHidden = true + resultBanner.lineBreakMode = .byWordWrapping + resultBanner.maximumNumberOfLines = 3 + resultBanner.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 + providers.onToggle = { [weak self] name, disable in + self?.onToggleProvider?(name, disable) + } + + body.orientation = .vertical + body.alignment = .leading + body.spacing = Theme.rowGap + body.setViews( + [skeleton, metrics, timelineChart, metricsSeparator, models, quotaStack, quotaEmpty, + accounts, providers, quotaSeparator, resultBanner, guidanceLabel, commandField], + in: .top + ) + body.translatesAutoresizingMaskIntoConstraints = false + + // A flipped clip view puts the scroll origin at the TOP. Without this, content + // that overflows opens scrolled to the bottom, hiding the status and metrics the + // urgency order exists to surface first. + scrollView.contentView = FlippedClipView() + scrollView.documentView = body + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + + let actions = NSStackView(views: [dashboardButton, stopButton, primaryButton, NSView(), overflowButton]) + actions.orientation = .horizontal + actions.spacing = Theme.rowGap + actions.alignment = .centerY + + let column = NSStackView(views: [header, makeSeparator(), scrollView, actions]) + column.orientation = .vertical + column.alignment = .leading + column.spacing = Theme.rowGap + column.edgeInsets = NSEdgeInsets( + top: Theme.gutter, left: Theme.gutter, + bottom: Theme.gutter, right: Theme.gutter + ) + column.translatesAutoresizingMaskIntoConstraints = false + + let root = NSView(frame: NSRect(x: 0, y: 0, width: Theme.width, height: 300)) + root.addSubview(column) + + let contentWidth = Theme.width - Theme.gutter * 2 + NSLayoutConstraint.activate([ + column.topAnchor.constraint(equalTo: root.topAnchor), + column.leadingAnchor.constraint(equalTo: root.leadingAnchor), + column.trailingAnchor.constraint(equalTo: root.trailingAnchor), + column.bottomAnchor.constraint(equalTo: root.bottomAnchor), + root.widthAnchor.constraint(equalToConstant: Theme.width), + header.widthAnchor.constraint(equalToConstant: contentWidth), + actions.widthAnchor.constraint(equalToConstant: contentWidth), + scrollView.widthAnchor.constraint(equalToConstant: contentWidth), + body.widthAnchor.constraint(equalToConstant: contentWidth), + ]) + + let heightConstraint = scrollView.heightAnchor.constraint(equalToConstant: 120) + heightConstraint.isActive = true + scrollHeight = heightConstraint + + view = root + } + + private func configureControls() { + for (button, title) in [(dashboardButton, "Dashboard"), (stopButton, "Stop proxy")] { + button.title = title + button.bezelStyle = .rounded + button.controlSize = .small + button.font = Theme.caption + button.target = self + } + dashboardButton.action = #selector(dashboardTapped) + stopButton.action = #selector(stopTapped) + + primaryButton.bezelStyle = .rounded + primaryButton.controlSize = .small + primaryButton.font = Theme.caption + primaryButton.target = self + primaryButton.action = #selector(primaryTapped) + primaryButton.isHidden = true + + overflowButton.title = "···" + overflowButton.bezelStyle = .rounded + overflowButton.controlSize = .small + overflowButton.font = Theme.caption + overflowButton.target = self + overflowButton.action = #selector(overflowTapped) + overflowButton.setAccessibilityLabel("More actions") + + quotaStack.orientation = .vertical + quotaStack.alignment = .leading + quotaStack.spacing = Theme.tightGap + + commandField.font = Theme.numericSmall + commandField.textColor = Theme.text + commandField.isSelectable = true + commandField.isBordered = false + commandField.drawsBackground = false + } + + public func apply(_ snapshot: ProxySnapshot) { + self.snapshot = snapshot + header.apply(snapshot) + + let showsData = snapshot.showsData + let isLoading = !snapshot.hasEverLoaded && snapshot.state == .loading + + // Loading shows structure, not empty copy: the shape of the answer is already + // known, only the values are missing. + skeleton.isHidden = !isLoading + + metrics.isHidden = !showsData + metricsSeparator.isHidden = !showsData + quotaSeparator.isHidden = !showsData + if showsData { + metrics.apply(snapshot) + timelineChart.apply(snapshot) + models.apply(snapshot) + accounts.apply(snapshot) + applyQuotas(snapshot) + providers.apply(snapshot) + } else { + timelineChart.isHidden = true + models.isHidden = true + accounts.isHidden = true + quotaStack.isHidden = true + quotaEmpty.isHidden = true + providers.isHidden = true + } + + applyGuidance(snapshot) + applyActions(snapshot, isLoading: isLoading) + resize() + } + + private func applyQuotas(_ snapshot: ProxySnapshot) { + for view in quotaStack.arrangedSubviews { + quotaStack.removeArrangedSubview(view) + view.removeFromSuperview() + } + let rows = snapshot.quotaRows + quotaStack.isHidden = rows.isEmpty + // "Not fetched yet" and "the proxy reported none" are different facts. + quotaEmpty.isHidden = !(rows.isEmpty && snapshot.quotasLoaded) + for quota in rows { + let row = QuotaRowView(quota: quota) + row.translatesAutoresizingMaskIntoConstraints = false + quotaStack.addArrangedSubview(row) + row.widthAnchor.constraint(equalTo: quotaStack.widthAnchor).isActive = true + } + } + + /// Shows the outcome of a write action, then clears itself. A banner that never + /// leaves would become permanent furniture. + public func showResult(_ text: String, isError: Bool) { + resultBanner.stringValue = text + resultBanner.textColor = isError ? Theme.red : Theme.muted + resultBanner.isHidden = false + refreshSize() + + resultToken &+= 1 + let token = resultToken + DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in + guard let self, self.resultToken == token else { return } + self.resultBanner.isHidden = true + self.refreshSize() + } + } + + public func revertProvider(_ name: String, to enabled: Bool) { + providers.revert(name, to: enabled) + } + + public func setProviderBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { + providers.setBusy(name, busy, intended: intended) + } + + /// Re-measures after content changes height (disclosure, banner). + public func refreshSize() { resize() } + + /// Guidance text plus any command the user should run. Commands are shown as + /// selectable text; the app never executes them. + private func applyGuidance(_ snapshot: ProxySnapshot) { + var guidance: String? + var command: String? + + switch snapshot.nextAction { + case .none: + // A running-but-at-risk proxy still has advice worth surfacing. + if case .running = snapshot.state, let recommended = snapshot.recommendedCommand { + guidance = "Recommended:" + command = recommended + } + case .runCommand(let value): + guidance = "Start it again with:" + command = value + case .addAPIKey: + guidance = "This proxy is bound to a non-loopback address and needs a key." + case .retry: + guidance = snapshot.dataAge.map { "Showing data from \(Format.age($0)). Retrying automatically." } + ?? "Retrying automatically." + } + + guidanceLabel.isHidden = guidance == nil + guidanceLabel.stringValue = guidance ?? "" + commandField.isHidden = command == nil + commandField.stringValue = command ?? "" + if let command { + commandField.setAccessibilityLabel("Command to run: \(command)") + } + } + + private func applyActions(_ snapshot: ProxySnapshot, isLoading: Bool) { + // Nothing is actionable before the first read completes. + dashboardButton.isEnabled = !isLoading + overflowButton.isEnabled = !isLoading + stopButton.isEnabled = snapshot.state.isRunning + stopButton.isHidden = !snapshot.state.isRunning + + switch snapshot.nextAction { + case .addAPIKey: + primaryButton.isHidden = false + primaryButton.title = "Add key…" + primaryButton.keyEquivalent = "\r" + case .retry: + primaryButton.isHidden = false + primaryButton.title = "Retry" + primaryButton.keyEquivalent = "\r" + case .none, .runCommand: + primaryButton.isHidden = true + primaryButton.keyEquivalent = "" + } + } + + private func resize() { + view.layoutSubtreeIfNeeded() + let bodyHeight = ceil(body.fittingSize.height) + // Chrome is the header, separator, action row, and insets. + let chrome = ceil(header.fittingSize.height) + Theme.gutter * 2 + Theme.rowGap * 3 + 28 + let natural = chrome + bodyHeight + let capped = min(Self.maxHeight, natural) + // Scrollers appear only when the content genuinely overflows; a scroll bar on a + // three-line loading state reads as a broken layout. + let overflowing = natural > Self.maxHeight + scrollView.hasVerticalScroller = overflowing + scrollHeight?.constant = max(0, capped - chrome) + preferredContentSize = NSSize(width: Theme.width, height: max(96, capped)) + } + + // MARK: - Actions + + @objc private func dashboardTapped() { onDashboard?() } + @objc private func stopTapped() { onStop?() } + @objc private func primaryTapped() { + switch snapshot?.nextAction { + case .addAPIKey: onAddKey?() + case .retry: onRetry?() + default: break + } + } + @objc private func refreshTapped() { onRefresh?() } + @objc private func quitTapped() { onQuit?() } + + @objc private func overflowTapped() { + let menu = NSMenu() + menu.addItem(withTitle: "Refresh", action: #selector(refreshTapped), keyEquivalent: "r").target = self + menu.addItem(withTitle: "Open dashboard", action: #selector(dashboardTapped), keyEquivalent: "").target = self + menu.addItem(withTitle: "Companion settings…", action: #selector(companionSettingsTapped), keyEquivalent: "").target = self + menu.addItem(.separator()) + menu.addItem(withTitle: "Quit OpenCodex", action: #selector(quitTapped), keyEquivalent: "q").target = self + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: overflowButton.bounds.height + 4), in: overflowButton) + } + @objc private func companionSettingsTapped() { onCompanionSettings?() } + + /// AppKit routes Escape here for the whole responder chain, which `keyDown` does not + /// reliably receive inside a popover. + public override func cancelOperation(_ sender: Any?) { + view.window?.performClose(nil) + } +} + +/// Top-anchored clip view. AppKit scroll views are bottom-origin by default. +final class FlippedClipView: NSClipView { + override var isFlipped: Bool { true } +} + +/// Loading structure: grey bars where values will appear, so the first paint shows the +/// shape of the answer instead of empty space or a spinner. +final class SkeletonView: NSView { + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: 84) + } + + override func draw(_ dirtyRect: NSRect) { + Theme.raised.setFill() + let widths: [CGFloat] = [72, 0, 96, 140, 120, 110] + var y = bounds.maxY - 12 + for width in widths { + guard width > 0 else { y -= 8; continue } + let rect = NSRect(x: 0, y: y, width: width, height: 9) + NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill() + y -= 15 + } + } +} diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift new file mode 100644 index 00000000000..c0d5b6dc0b2 --- /dev/null +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -0,0 +1,255 @@ +import AppKit +import MenuBarCore + +/// Collapsed provider list with per-provider enable/disable switches. +/// +/// Collapsed by default: reading status is frequent, toggling a provider is rare, and +/// the urgency order in `003` puts actions below information. +public final class ProviderListView: NSView { + private let disclosure = NSButton() + private let summary = makeLabel("", font: Theme.caption, color: Theme.muted) + private let rows = NSStackView() + private var expanded = false + private var snapshot: ProxySnapshot? + /// Providers with a write in flight, mapped to the state the USER chose. A poll can + /// still be carrying pre-write data, so the intended value — not the snapshot — is + /// what a rebuilt row must show. + private var pending: [String: Bool] = [:] + + /// `(provider, shouldDisable)`. + public var onToggle: ((String, Bool) -> Void)? + + public override init(frame: NSRect) { + super.init(frame: frame) + + disclosure.bezelStyle = .disclosure + disclosure.setButtonType(.onOff) + disclosure.title = "" + disclosure.target = self + disclosure.action = #selector(toggleExpanded) + disclosure.setAccessibilityLabel("Show providers") + + rows.orientation = .vertical + rows.alignment = .leading + rows.spacing = Theme.tightGap + rows.isHidden = true + + let header = NSStackView(views: [disclosure, summary]) + header.orientation = .horizontal + header.spacing = Theme.tightGap + header.alignment = .centerY + + let column = NSStackView(views: [header, rows]) + column.orientation = .vertical + column.alignment = .leading + column.spacing = Theme.tightGap + column.translatesAutoresizingMaskIntoConstraints = false + addSubview(column) + NSLayoutConstraint.activate([ + column.topAnchor.constraint(equalTo: topAnchor), + column.leadingAnchor.constraint(equalTo: leadingAnchor), + column.trailingAnchor.constraint(equalTo: trailingAnchor), + column.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + public convenience init() { self.init(frame: .zero) } + + public required init?(coder: NSCoder) { nil } + + public func apply(_ snapshot: ProxySnapshot) { + self.snapshot = snapshot + + guard snapshot.providersLoaded else { + isHidden = true + return + } + isHidden = false + + if snapshot.providers.isEmpty { + summary.stringValue = "No providers configured." + disclosure.isHidden = true + rows.isHidden = true + return + } + + disclosure.isHidden = false + let enabled = snapshot.providers.filter(\.isEnabled).count + summary.stringValue = "\(enabled) of \(snapshot.providers.count) providers enabled" + rebuildRows(snapshot) + rows.isHidden = !expanded + } + + private func rebuildRows(_ snapshot: ProxySnapshot) { + for view in rows.arrangedSubviews { + rows.removeArrangedSubview(view) + view.removeFromSuperview() + } + + for provider in snapshot.visibleProviders.sorted(by: { $0.name < $1.name }) { + let isDefault = provider.name == snapshot.defaultProvider + let row = ProviderRowView( + provider: provider, + isDefault: isDefault + ) { [weak self] shouldDisable in + self?.onToggle?(provider.name, shouldDisable) + } + // A refresh that lands mid-write must not undo the optimistic state: apply + // the intended value first, then mark the row busy. + if let intended = pending[provider.name] { + row.setEnabled(intended) + row.setBusy(true) + } + row.translatesAutoresizingMaskIntoConstraints = false + rows.addArrangedSubview(row) + row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true + } + } + + /// Shared by the disclosure button and the test hook. + func setExpanded(_ value: Bool) { + expanded = value + disclosure.state = value ? .on : .off + rows.isHidden = !expanded + disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") + (window?.contentViewController as? PopoverViewController)?.refreshSize() + } + + var providerRows: [NSView] { rows.arrangedSubviews } + + @objc private func toggleExpanded() { + expanded = disclosure.state == .on + rows.isHidden = !expanded + disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") + // The popover has to grow or shrink with the disclosure. + (window?.contentViewController as? PopoverViewController)?.refreshSize() + } + + /// Reverts a switch after the proxy rejected the change. + public func revert(_ name: String, to enabled: Bool) { + pending[name] = nil + for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { + row.setEnabled(enabled) + row.setBusy(false) + } + } + + /// Marks a provider as having a write in flight. Its switch stays inert until the + /// authoritative refresh lands, so a poll cannot resurrect the pre-toggle state and + /// a second click cannot race the first. + /// `intended` is the state the user selected, retained so a poll landing mid-write + /// cannot snap the switch back. + public func setBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { + if busy { + pending[name] = intended ?? pending[name] ?? true + } else { + pending[name] = nil + } + for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { + if busy, let value = pending[name] { row.setEnabled(value) } + row.setBusy(busy) + } + } +} + +public final class ProviderRowView: NSView { + public let providerName: String + private let toggle = NSSwitch() + private let onToggle: (Bool) -> Void + private var baseEnabled = true + private var isBusy = false + + init(provider: ProviderSummary, isDefault: Bool, onToggle: @escaping (Bool) -> Void) { + self.providerName = provider.name + self.onToggle = onToggle + super.init(frame: .zero) + + let name = makeLabel(provider.name, font: Theme.caption, color: Theme.text) + let detail = makeLabel( + isDefault ? "default" : (provider.authMode ?? ""), + font: Theme.micro, + color: Theme.faint + ) + + let labels = NSStackView(views: [name, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 0 + + toggle.state = provider.isEnabled ? .on : .off + toggle.controlSize = .mini + toggle.target = self + toggle.action = #selector(switched) + + // The proxy rejects only DISABLING the default provider (`provider-routes.ts:178` + // guards on `rawBody.disabled && name === defaultProvider`). Enabling it is + // valid, so a default provider that is currently off must stay toggleable — + // otherwise the app strands the user in a state it cannot leave. + let wouldDisableDefault = isDefault && provider.isEnabled + toggle.isEnabled = !wouldDisableDefault + toggle.toolTip = wouldDisableDefault + ? "This is the default provider. Choose another default in the dashboard first." + : nil + baseEnabled = toggle.isEnabled + toggle.setAccessibilityLabel("\(provider.name) enabled") + + let row = NSStackView(views: [labels, NSView(), toggle]) + row.orientation = .horizontal + row.spacing = Theme.rowGap + row.alignment = .centerY + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + NSLayoutConstraint.activate([ + row.topAnchor.constraint(equalTo: topAnchor), + row.leadingAnchor.constraint(equalTo: leadingAnchor), + row.trailingAnchor.constraint(equalTo: trailingAnchor), + row.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func setEnabled(_ enabled: Bool) { toggle.state = enabled ? .on : .off } + + var toggleState: Bool { toggle.state == .on } + var toggleIsEnabled: Bool { toggle.isEnabled } + + /// Inert while its write is in flight, so a second click cannot race the first. + func setBusy(_ busy: Bool) { + isBusy = busy + toggle.isEnabled = busy ? false : baseEnabled + alphaValue = busy ? 0.6 : 1 + } + + @objc private func switched() { + // Optimistic: the switch has already moved. The caller reverts on failure. + onToggle(toggle.state == .off) + } +} + + +// MARK: - Test inspection + +/// Read-only hooks so the UI suite can assert on rendered control state rather than on +/// the view's private bookkeeping. +package extension ProviderListView { + /// Expands the list without going through a click, so tests do not depend on + /// NSButton action dispatch. + func expandForTesting() { setExpanded(true) } + + func isToggleOn(_ name: String) -> Bool? { row(name)?.isOn } + func isToggleEnabled(_ name: String) -> Bool? { row(name)?.isToggleEnabled } + func hasProviderForTesting(_ name: String) -> Bool { row(name) != nil } + + private func row(_ name: String) -> ProviderRowView? { + for case let row as ProviderRowView in providerRows where row.providerName == name { + return row + } + return nil + } +} + +package extension ProviderRowView { + var isOn: Bool { toggleState } + var isToggleEnabled: Bool { toggleIsEnabled } +} diff --git a/app/Sources/MenuBarUI/StatusIcon.swift b/app/Sources/MenuBarUI/StatusIcon.swift new file mode 100644 index 00000000000..e7eb2951b17 --- /dev/null +++ b/app/Sources/MenuBarUI/StatusIcon.swift @@ -0,0 +1,73 @@ +import AppKit +import MenuBarCore + +/// The menu bar glyph. +/// +/// Drawn as vector paths rather than shipped as PNGs, so it stays crisp at every scale +/// factor and inverts correctly as a template image. +/// +/// Colour is deliberately absent here. macOS menu bar items are monochrome by +/// convention, and a coloured dot up there is the tell of an app that does not respect +/// the platform. State is carried by fill and by a notch instead. The coloured dot lives +/// inside the popover, where it sits beside a word and so never encodes meaning by +/// colour alone. +public enum StatusIcon { + public static let size = NSSize(width: 17, height: 17) + + public static func image(for state: ProxyState) -> NSImage { + switch state { + case .running(let health) where health.isProtected: + return mark(filled: true, notched: false, alpha: 1) + case .running: + return mark(filled: true, notched: true, alpha: 1) + case .loading, .degraded: + return mark(filled: false, notched: false, alpha: 1) + case .unreachable, .unauthorized: + return mark(filled: false, notched: false, alpha: 0.4) + } + } + + /// A rounded mark reduced to menu bar scale. + /// + /// The notch is carved out of the geometry with an even-odd path rather than by + /// compositing. An earlier version stroked with `.clear` and `.clear` composite mode, + /// which silently did nothing — the rendered at-risk glyph was indistinguishable from + /// the protected one, so the state signal was invisible. + private static func mark(filled: Bool, notched: Bool, alpha: CGFloat) -> NSImage { + let image = NSImage(size: size, flipped: false) { rect in + let inset = rect.insetBy(dx: 2.5, dy: 2.5) + let path = NSBezierPath(roundedRect: inset, xRadius: 4, yRadius: 4) + + if notched { + // A slot carved out of the trailing edge, kept fully inside the mark so + // the silhouette stays clean. Even-odd winding turns the subpath into a + // hole rather than a second filled shape. + let notch = NSBezierPath( + roundedRect: NSRect( + x: inset.maxX - 4.2, + y: inset.midY - 1.1, + width: 3.0, + height: 2.2 + ), + xRadius: 1.1, + yRadius: 1.1 + ) + path.append(notch) + path.windingRule = .evenOdd + } + + NSColor.black.withAlphaComponent(alpha).setStroke() + NSColor.black.withAlphaComponent(alpha).setFill() + + if filled { + path.fill() + } else { + path.lineWidth = 1.6 + path.stroke() + } + return true + } + image.isTemplate = true + return image + } +} diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift new file mode 100644 index 00000000000..b1e8b4241f4 --- /dev/null +++ b/app/Sources/MenuBarUI/Theme.swift @@ -0,0 +1,103 @@ +import AppKit + +/// Tokens derived from `gui/src/styles.css` so the companion and the dashboard agree on +/// what "healthy" looks like. +/// +/// For SURFACES, AppKit's semantic colours win over a hardcoded hex: they track +/// light/dark plus the increased-contrast and vibrancy accessibility settings, which a +/// literal cannot. +/// +/// The TEXT tiers are a deliberate exception. Measured against the popover material, +/// `tertiaryLabelColor` renders at 2.01:1 in light and 2.39:1 in dark — it is designed +/// for disabled affordances, not for information the user has to read. All four text and +/// mark tokens below are therefore calibrated against the rendered material and verified +/// numerically rather than trusted by name. +enum Theme { + // Surfaces + static let separator = NSColor.separatorColor + static let raised = NSColor.controlBackgroundColor + + // Text: --text / --muted / --faint + // + // `tertiaryLabelColor` measured 2.01:1 in light and 2.39:1 in dark against the + // popover material — well under the 4.5:1 required for normal text. AppKit's + // tertiary tier is intended for disabled affordances, not for information the user + // has to read, and every label using this tier here (range heading, metric captions, + // quota window labels) carries real meaning. Calibrated tokens replace it. + /// All three text tiers are calibrated against the RENDERED popover material, not + /// picked from AppKit's semantic palette. Measured backgrounds: light (220,219,218), + /// dark (102,101,101). + /// + /// The dark material constrains this hard — pure white measures only 5.81:1 against + /// it — so the tiers are packed into the band that remains while keeping every text + /// tier above 4.5:1 and preserving `text > muted > faint` in both appearances. + static let text = dynamic(light: 0x1A1A1A, dark: 0xFFFFFF) + static let muted = dynamic(light: 0x3D3D3D, dark: 0xF2F2F2) + /// Small supporting text that must still be legible: 10-11pt captions and labels. + static let faint = dynamic(light: 0x545454, dark: 0xEDEDED) + /// Graphical marks only, held to the 3:1 non-text threshold rather than 4.5:1. + static let graphMark = dynamic(light: 0x707070, dark: 0xD2D2D2) + + // State colours, taken verbatim from styles.css. + static let green = dynamic(light: 0x0A7D5C, dark: 0x4ECB9D) + static let amber = dynamic(light: 0x9A4A08, dark: 0xFBBF24) + static let red = dynamic(light: 0xB91C1C, dark: 0xF87171) + + // Type ladder: --text-micro / --text-caption / --text-label / --text-control. + static let micro = NSFont.systemFont(ofSize: 10, weight: .medium) + static let caption = NSFont.systemFont(ofSize: 11) + static let label = NSFont.systemFont(ofSize: 12, weight: .semibold) + /// Monospaced digits are the AppKit equivalent of `font-variant-numeric: tabular-nums`. + /// Without this, polling makes every digit jitter. + static let numeric = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) + static let numericSmall = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + + // Geometry: --space-* and --radius-sm. + static let gutter: CGFloat = 12 + static let rowGap: CGFloat = 8 + static let tightGap: CGFloat = 4 + static let radius: CGFloat = 8 + static let width: CGFloat = 340 + + static func color(for tone: ProxyToneBridge) -> NSColor { + switch tone { + case .neutral: return muted + case .good: return green + case .warning: return amber + case .bad: return red + } + } + + /// Quota fill: green under 80, amber to 95, red above. The percentage is always + /// printed beside the bar, so colour is reinforcement rather than the only signal. + static func quotaColor(percent: Double?) -> NSColor { + guard let percent else { return faint } + if percent > 95 { return red } + if percent >= 80 { return amber } + return green + } + + /// `light-dark()` equivalent: resolves per appearance instead of at creation time. + private static func dynamic(light: Int, dark: Int) -> NSColor { + NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return NSColor(hex: isDark ? dark : light) + } + } +} + +/// Mirrors `ProxyState.Tone` without importing AppKit into the core module. +enum ProxyToneBridge { + case neutral, good, warning, bad +} + +extension NSColor { + convenience init(hex: Int) { + self.init( + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1 + ) + } +} diff --git a/app/Sources/MenuBarUI/TimelineChartView.swift b/app/Sources/MenuBarUI/TimelineChartView.swift new file mode 100644 index 00000000000..56a8a6288f1 --- /dev/null +++ b/app/Sources/MenuBarUI/TimelineChartView.swift @@ -0,0 +1,135 @@ +import AppKit +import MenuBarCore + +public final class TimelineChartView: NSView { + private var timeline: UsageTimeline? + private var settings = CompanionSettings.defaults + private let colors = [0x0A84FF, 0xFF9F0A, 0x30D158, 0xBF5AF2, 0xFF453A, 0x64D2FF] + + public override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: 104) + } + + public func apply(_ snapshot: ProxySnapshot) { + settings = snapshot.settings + timeline = snapshot.timeline + isHidden = !settings.showChart || timeline == nil + setAccessibilityLabel("Usage timeline") + needsDisplay = true + } + + public override func draw(_ dirtyRect: NSRect) { + guard let timeline, !timeline.isEmpty else { + if settings.showChart { + drawText("No token usage in this window.", in: NSRect(x: 0, y: 36, width: bounds.width, height: 16), font: Theme.caption, color: Theme.muted) + } + return + } + let chartHeight: CGFloat = 72 + let maxValue = settings.chartStyle == .stackedBar ? timeline.stackedMax : timeline.maxPoint + drawText(Format.tokens(Int(maxValue.rounded())), in: NSRect(x: 0, y: chartHeight + 8, width: bounds.width, height: 14), font: Theme.micro, color: Theme.muted, alignment: .right) + let window = timeline.buckets * timeline.bucketSeconds / 3600 + let windowLabel: String + if window < 48 { + windowLabel = "\(window)h" + } else { + windowLabel = "\(window / 24)d" + } + drawText(windowLabel, in: NSRect(x: 0, y: chartHeight + 8, width: 40, height: 14), font: Theme.micro, color: Theme.muted) + + let plot = NSRect(x: 0, y: 20, width: bounds.width, height: chartHeight) + Theme.muted.setStroke() + let baseline = NSBezierPath() + baseline.move(to: NSPoint(x: plot.minX, y: plot.minY)) + baseline.line(to: NSPoint(x: plot.maxX, y: plot.minY)) + baseline.lineWidth = 0.5 + baseline.stroke() + + if settings.chartStyle == .stackedBar { + drawBars(timeline, in: plot, maxValue: maxValue) + } else { + drawLines(timeline, in: plot, maxValue: maxValue) + } + + drawLegend(timeline, in: NSRect(x: 0, y: 0, width: bounds.width, height: 14)) + } + + private func drawText( + _ text: String, in rect: NSRect, font: NSFont, color: NSColor, alignment: NSTextAlignment = .left + ) { + let style = NSMutableParagraphStyle() + style.alignment = alignment + NSAttributedString( + string: text, + attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style] + ).draw(in: rect) + } + + private func drawLines(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { + guard timeline.buckets > 1, maxValue > 0 else { return } + for (seriesIndex, series) in timeline.series.enumerated() { + let path = NSBezierPath() + for (index, value) in series.points.enumerated() { + let x = plot.minX + plot.width * CGFloat(index) / CGFloat(max(timeline.buckets - 1, 1)) + let y = plot.minY + plot.height * CGFloat(value / maxValue) + if index == 0 { path.move(to: NSPoint(x: x, y: y)) } else { path.line(to: NSPoint(x: x, y: y)) } + } + NSColor(hex: colors[seriesIndex % colors.count]).setStroke() + path.lineWidth = 1.5 + path.stroke() + } + } + + private func drawBars(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { + guard timeline.buckets > 0, maxValue > 0 else { return } + let width = max(1, plot.width / CGFloat(timeline.buckets) - 1) + for bucket in 0.. 0 { + let extra = entries.count - visible + let suffixWidth = extra > 0 + ? NSAttributedString(string: "+\(extra) more", attributes: attributes).size().width + separator + : 0 + let entryWidth = entries.prefix(visible).reduce(CGFloat.zero) { width, entry in + width + dotSize + 4 + entry.1.size().width + separator + } + if entryWidth + suffixWidth <= rect.width || visible == 0 { break } + visible -= 1 + } + let extra = entries.count - visible + var x = rect.minX + for (index, text) in entries.prefix(visible) { + let dot = NSRect(x: x, y: rect.midY - dotSize / 2, width: dotSize, height: dotSize) + NSColor(hex: colors[index % colors.count]).setFill() + NSBezierPath(ovalIn: dot).fill() + x += dotSize + 4 + text.draw(at: NSPoint(x: x, y: rect.minY)) + x += text.size().width + separator + } + if extra > 0 { + NSAttributedString(string: "+\(extra) more", attributes: attributes) + .draw(at: NSPoint(x: x, y: rect.minY)) + } + } +} diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift new file mode 100644 index 00000000000..70c229b6859 --- /dev/null +++ b/app/Sources/MenuBarUI/Views.swift @@ -0,0 +1,264 @@ +import AppKit +import MenuBarCore + +// MARK: - Shared helpers + +func makeLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField { + let field = NSTextField(labelWithString: text) + field.font = font + field.textColor = color + field.lineBreakMode = .byTruncatingTail + return field +} + +func makeRow(_ views: [NSView], spacing: CGFloat = Theme.rowGap) -> NSStackView { + let stack = NSStackView(views: views) + stack.orientation = .horizontal + stack.spacing = spacing + stack.alignment = .firstBaseline + return stack +} + +func makeSeparator() -> NSView { + let line = NSView() + line.wantsLayer = true + line.layer?.backgroundColor = Theme.separator.cgColor + line.translatesAutoresizingMaskIntoConstraints = false + line.heightAnchor.constraint(equalToConstant: 1).isActive = true + return line +} + +// MARK: - Status header + +/// `● Running 127.0.0.1:10100` +/// +/// The dot never travels alone: the word beside it carries the same meaning, so the UI +/// stays readable without colour perception (WCAG 1.4.1). +final class StatusHeaderView: NSView { + private let dot = StatusDotView() + private let title = makeLabel("", font: Theme.label, color: Theme.text) + private let endpoint = makeLabel("", font: Theme.caption, color: Theme.muted) + private let detail = makeLabel("", font: Theme.caption, color: Theme.muted) + + init() { + super.init(frame: .zero) + let top = makeRow([dot, title, NSView(), endpoint], spacing: Theme.rowGap) + top.alignment = .centerY + top.distribution = .fill + endpoint.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let stack = NSStackView(views: [top, detail]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 2 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + let state = snapshot.state + title.stringValue = state.title + endpoint.stringValue = snapshot.endpoint.display + dot.tone = bridge(state.tone) + + if let text = state.detail { + detail.stringValue = text + detail.isHidden = false + } else { + detail.isHidden = true + } + + setAccessibilityLabel("Proxy \(state.title) at \(snapshot.endpoint.display)") + } + + private func bridge(_ tone: ProxyState.Tone) -> ProxyToneBridge { + switch tone { + case .neutral: return .neutral + case .good: return .good + case .warning: return .warning + case .bad: return .bad + } + } +} + +final class StatusDotView: NSView { + var tone: ProxyToneBridge = .neutral { + didSet { needsDisplay = true } + } + + override var intrinsicContentSize: NSSize { NSSize(width: 8, height: 8) } + + override func draw(_ dirtyRect: NSRect) { + let rect = NSRect(x: 0, y: (bounds.height - 8) / 2, width: 8, height: 8) + Theme.color(for: tone).setFill() + NSBezierPath(ovalIn: rect).fill() + } +} + +// MARK: - Metrics + +/// Three columns plus a range header that echoes the response, never the request. +final class MetricsView: NSView { + private let rangeLabel = makeLabel("USAGE", font: Theme.micro, color: Theme.faint) + private let columns: [(caption: NSTextField, value: NSTextField)] + private let emptyLabel = makeLabel("", font: Theme.caption, color: Theme.muted) + private let stack: NSStackView + private let columnsRow: NSStackView + + init() { + let captions = ["TOKENS", "REQUESTS", "COST"] + columns = captions.map { caption in + (makeLabel(caption, font: Theme.micro, color: Theme.faint), + makeLabel(Format.unknown, font: Theme.numeric, color: Theme.text)) + } + + let columnViews: [NSView] = columns.map { pair in + let column = NSStackView(views: [pair.caption, pair.value]) + column.orientation = .vertical + column.alignment = .leading + column.spacing = 1 + return column + } + columnsRow = NSStackView(views: columnViews) + columnsRow.orientation = .horizontal + columnsRow.distribution = .fillEqually + columnsRow.alignment = .top + + stack = NSStackView(views: [rangeLabel, columnsRow, emptyLabel]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = Theme.tightGap + + super.init(frame: .zero) + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + let usage = snapshot.today ?? snapshot.usage + isHidden = !snapshot.settings.showToday + rangeLabel.stringValue = usage?.rangeLabel ?? "USAGE" + columnsRow.arrangedSubviews[2].isHidden = !snapshot.settings.showCost + + // Three states: known-empty gets copy, unknown gets em dashes, data gets values. + switch snapshot.usageIsEmpty { + case .some(true): + columnsRow.isHidden = true + emptyLabel.isHidden = false + emptyLabel.stringValue = "No requests in this period." + default: + columnsRow.isHidden = false + emptyLabel.isHidden = true + let summary = usage?.summary + let requests = Format.count(summary?.requests) + columns[0].value.stringValue = Format.tokens(summary?.totalTokens) + columns[1].value.stringValue = (summary?.hasEstimates ?? false) ? requests + "~" : requests + columns[2].value.stringValue = Format.cost(summary?.estimatedCostUsd) + columns[0].value.setAccessibilityLabel( + "\(Format.tokens(summary?.totalTokens)) tokens" + ) + columns[1].value.setAccessibilityLabel( + (summary?.hasEstimates ?? false) + ? "\(requests) requests, partly estimated" + : "\(requests) requests" + ) + columns[2].value.setAccessibilityLabel( + "\(Format.cost(summary?.estimatedCostUsd)) estimated cost" + ) + } + } +} + +// MARK: - Quotas + +/// `OpenAI ▓▓▓▓▓░░░░░ 44%` +final class QuotaRowView: NSView { + init(quota: NormalizedQuota) { + super.init(frame: .zero) + + let name = makeLabel(quota.providerLabel, font: Theme.caption, color: Theme.text) + name.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + name.lineBreakMode = .byTruncatingTail + + // Which window a number belongs to is not decoration: 42% of an API-usage window + // and 42% of a month mean very different things. + let window = makeLabel( + quota.hasPercent ? quota.windowLabel : "", + font: Theme.micro, color: Theme.faint + ) + + let labels = NSStackView(views: [name, window]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 0 + + let bar = QuotaBarView() + bar.percent = quota.percent + + let value = makeLabel(Format.percent(quota.percent), font: Theme.numericSmall, color: Theme.muted) + value.alignment = .right + + let row = NSStackView(views: [labels, bar, value]) + row.orientation = .horizontal + row.spacing = Theme.rowGap + row.alignment = .centerY + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + + NSLayoutConstraint.activate([ + row.topAnchor.constraint(equalTo: topAnchor), + row.leadingAnchor.constraint(equalTo: leadingAnchor), + row.trailingAnchor.constraint(equalTo: trailingAnchor), + row.bottomAnchor.constraint(equalTo: bottomAnchor), + labels.widthAnchor.constraint(equalToConstant: 132), + value.widthAnchor.constraint(equalToConstant: 36), + ]) + + // The percentage is spoken, not merely drawn as a filled width. + let reset = Format.resetsIn(quota.resetAt) + setAccessibilityLabel( + quota.hasPercent + ? "\(quota.providerLabel): \(Format.percent(quota.percent)) of \(quota.windowLabel) quota, resets in \(reset)" + : "\(quota.providerLabel): quota unknown" + ) + } + + required init?(coder: NSCoder) { nil } +} + +final class QuotaBarView: NSView { + var percent: Double? + + override var intrinsicContentSize: NSSize { NSSize(width: 110, height: 6) } + + override func draw(_ dirtyRect: NSRect) { + let track = NSRect(x: 0, y: (bounds.height - 6) / 2, width: bounds.width, height: 6) + Theme.raised.setFill() + NSBezierPath(roundedRect: track, xRadius: 3, yRadius: 3).fill() + + // A nil percent draws no fill at all — a zero-width bar would read as "0% used", + // which is a different fact from "unknown". + guard let percent else { return } + let clamped = max(0, min(100, percent)) + guard clamped > 0 else { return } + let fill = NSRect(x: 0, y: track.origin.y, width: track.width * CGFloat(clamped / 100), height: 6) + Theme.quotaColor(percent: percent).setFill() + NSBezierPath(roundedRect: fill, xRadius: 3, yRadius: 3).fill() + } +} diff --git a/app/Sources/MenuBarUITests/Harness.swift b/app/Sources/MenuBarUITests/Harness.swift new file mode 100644 index 00000000000..0deb1d6ae46 --- /dev/null +++ b/app/Sources/MenuBarUITests/Harness.swift @@ -0,0 +1,105 @@ +import Foundation + +/// A dependency-free assertion harness. +/// +/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line +/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles +/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to +/// run the unit tests of a menu bar companion would put the tests out of reach for most +/// contributors and for any CI runner without Xcode selected. +/// +/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that +/// both a human and CI can read. If the package ever gains a full-Xcode requirement for +/// other reasons, migrating these cases to swift-testing is mechanical. +public struct TestFailure { + let test: String + let message: String + let file: String + let line: Int +} + +public final class TestRunner { + private(set) var passed = 0 + private(set) var failures: [TestFailure] = [] + private var current = "" + + public init() {} + + public func test(_ name: String, _ body: () throws -> Void) { + current = name + let failuresBefore = failures.count + do { + try body() + } catch { + failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) + print("FAIL — \(name): threw \(error)") + return + } + // A case that recorded an expectation failure is not a pass, even though its + // body returned normally. + if failures.count == failuresBefore { + passed += 1 + print("ok — \(name)") + } + } + + public func expect( + _ condition: Bool, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + guard !condition else { return } + let failure = TestFailure(test: current, message: message(), file: file, line: line) + failures.append(failure) + print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") + } + + public func equal( + _ actual: T, + _ expected: T, + _ label: String = "", + file: String = #file, + line: Int = #line + ) { + expect( + actual == expected, + "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", + file: file, + line: line + ) + } + + public func notNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) -> T? { + expect(value != nil, "\(label) should not be nil", file: file, line: line) + return value + } + + public func isNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) { + expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) + } + + /// Prints the summary and returns the process exit code. + public func summarize() -> Int32 { + print("") + if failures.isEmpty { + print("\(passed) passed, 0 failed") + return 0 + } + print("\(passed) passed, \(failures.count) FAILED") + for failure in failures { + print(" - \(failure.test): \(failure.message)") + } + return 1 + } +} diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift new file mode 100644 index 00000000000..daf7e872b20 --- /dev/null +++ b/app/Sources/MenuBarUITests/main.swift @@ -0,0 +1,165 @@ +import AppKit +import MenuBarCore +import MenuBarUI + +// UI-layer tests. Separate from MenuBarCoreTests because these need AppKit and an +// NSApplication; the core suite deliberately has no UI dependency. +// +// These cover the Phase 3 behaviours that were defects in earlier review rounds: +// optimistic rollback, pending state surviving a poll, and the direction-sensitive +// default-provider guard. + +let app = NSApplication.shared +app.setActivationPolicy(.prohibited) + +let runner = TestRunner() + +func provider(_ name: String, enabled: Bool = true) -> ProviderSummary { + let json = #"{"name":"\#(name)","disabled":\#(enabled ? "false" : "true")}"# + return try! JSONDecoder().decode(ProviderSummary.self, from: Data(json.utf8)) +} + +func snapshot( + providers: [ProviderSummary], + defaultProvider: String? = "openai" +) -> ProxySnapshot { + ProxySnapshot( + state: .running(StartupHealth(status: "protected")), + endpoint: .default, + providers: providers, + defaultProvider: defaultProvider, + lastUpdated: Date(), + providersLoaded: true + ) +} + +// MARK: - Default-provider guard direction + +runner.test("ui: an enabled default provider cannot be switched off") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("openai"), provider("anthropic")])) + list.expandForTesting() + + runner.equal(list.isToggleEnabled("openai"), false, "enabled default is inert") + runner.equal(list.isToggleEnabled("anthropic"), true, "non-default is toggleable") +} + +// The proxy guard is `disabled && name === defaultProvider`, so ENABLING the default is +// valid. Making the control inert whenever isDefault stranded the user. +runner.test("ui: a disabled default provider can still be switched back on") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("openai", enabled: false)])) + list.expandForTesting() + + runner.equal(list.isToggleEnabled("openai"), true, "disabled default must be recoverable") +} + +// MARK: - Optimistic update and rollback + +runner.test("ui: a rejected write restores the switch it moved") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("anthropic")])) + list.expandForTesting() + + // User switches it off; the write is in flight. + list.setBusy("anthropic", true, intended: false) + runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") + runner.equal(list.isToggleEnabled("anthropic"), false, "inert while in flight") + + // The proxy rejects it. + list.revert("anthropic", to: true) + runner.equal(list.isToggleOn("anthropic"), true, "reverted to the server's value") + runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") +} + +runner.test("ui: a successful write clears busy without reverting") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("anthropic")])) + list.expandForTesting() + + list.setBusy("anthropic", true, intended: false) + // The authoritative refresh now reports it disabled. + list.apply(snapshot(providers: [provider("anthropic", enabled: false)])) + list.setBusy("anthropic", false) + + runner.equal(list.isToggleOn("anthropic"), false, "server state retained") + runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") +} + +// MARK: - Pending state versus a stale poll + +// This is the defect a reviewer caught: rebuildRows initialised each switch from the +// snapshot, so a poll carrying pre-write data snapped the switch back mid-write. +runner.test("ui: a stale poll cannot undo an in-flight optimistic change") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("anthropic")])) + list.expandForTesting() + + list.setBusy("anthropic", true, intended: false) + runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") + + // A poll that started before the write lands, still reporting the old value. + list.apply(snapshot(providers: [provider("anthropic", enabled: true)])) + + runner.equal(list.isToggleOn("anthropic"), false, "stale poll must not snap it back") + runner.equal(list.isToggleEnabled("anthropic"), false, "still inert while in flight") +} + +runner.test("ui: pending state is per provider and does not leak") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("anthropic"), provider("xai")])) + list.expandForTesting() + + list.setBusy("anthropic", true, intended: false) + runner.equal(list.isToggleEnabled("anthropic"), false, "target is inert") + runner.equal(list.isToggleEnabled("xai"), true, "sibling is unaffected") + runner.equal(list.isToggleOn("xai"), true, "sibling keeps its value") +} + +// MARK: - Empty and unloaded states + +runner.test("ui: providers are hidden until they have actually been read") { + let list = ProviderListView() + var unloaded = snapshot(providers: []) + unloaded.providersLoaded = false + list.apply(unloaded) + runner.equal(list.isHidden, true, "not fetched yet is not the same as none") + + list.apply(snapshot(providers: [])) + runner.equal(list.isHidden, false, "an empty result renders its own copy") +} + +runner.test("ui: hidden providers do not create rows") { + let list = ProviderListView() + var current = snapshot(providers: [provider("openai"), provider("anthropic")]) + current.settings = CompanionSettings(hiddenProviders: ["openai"]) + list.apply(current) + list.expandForTesting() + runner.equal(list.hasProviderForTesting("openai"), false) + runner.equal(list.hasProviderForTesting("anthropic"), true) +} + +runner.test("ui: chart setting hides the timeline view") { + let chart = TimelineChartView() + var current = snapshot(providers: []) + current.timeline = try! JSONDecoder().decode( + UsageTimeline.self, + from: Data(#"{"start":0,"end":1,"bucketSeconds":1,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"#.utf8) + ) + current.settings = CompanionSettings(showChart: false) + chart.apply(current) + runner.equal(chart.isHidden, true) +} + +runner.test("ui: menu title renders from a companion template") { + let report = try! JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"today","summary":{"requests":3}}"#.utf8) + ) + var current = snapshot(providers: []) + current.today = report + current.settings = CompanionSettings(menuBarTemplate: "req {requests}") + runner.equal(current.menuBarTitle, "req 3") +} + +exit(runner.summarize()) diff --git a/app/Sources/OpenCodexWidget/Provider.swift b/app/Sources/OpenCodexWidget/Provider.swift new file mode 100644 index 00000000000..55d983bf5cf --- /dev/null +++ b/app/Sources/OpenCodexWidget/Provider.swift @@ -0,0 +1,49 @@ +import Foundation +import WidgetKit +import MenuBarCore + +@available(macOS 14, *) +public struct SnapshotEntry: TimelineEntry { + public let date: Date + public let snapshot: WidgetSnapshot? + public let failure: ReadFailure? + public let stale: Bool +} + +@available(macOS 14, *) +public struct SnapshotProvider: TimelineProvider { + private let reader = SnapshotReader() + + public init() {} + + public func placeholder(in context: Context) -> SnapshotEntry { + SnapshotEntry(date: Date(), snapshot: Self.sample, failure: nil, stale: false) + } + + public func getSnapshot(in context: Context, completion: @escaping (SnapshotEntry) -> Void) { + completion(readEntry()) + } + + public func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + completion(Timeline(entries: [readEntry(now: now)], policy: .after(now.addingTimeInterval(300)))) + } + + private func readEntry(now: Date = Date()) -> SnapshotEntry { + switch reader.read() { + case .failure(let failure): + return SnapshotEntry(date: now, snapshot: nil, failure: failure, stale: false) + case .success(let snapshot): + return SnapshotEntry(date: now, snapshot: snapshot, failure: nil, stale: snapshot.isStale(now: now)) + } + } + + private static let sample = WidgetSnapshot( + schemaVersion: 1, generatedAt: Date().timeIntervalSince1970, + state: "running", stateTitle: "Running", detail: "protected", + endpointDisplay: "127.0.0.1:10100", menuTitle: "12", + today: .init(requests: 12, totalTokens: 4_200, estimatedCostUsd: 0.12), + quotas: [.init(providerLabel: "OpenAI", windowLabel: "week", percent: 42, resetAt: Date().addingTimeInterval(86_400).timeIntervalSince1970)], + chart: nil, lastUpdated: Date().timeIntervalSince1970 + ) +} diff --git a/app/Sources/OpenCodexWidget/SnapshotReader.swift b/app/Sources/OpenCodexWidget/SnapshotReader.swift new file mode 100644 index 00000000000..0c3b8fc66be --- /dev/null +++ b/app/Sources/OpenCodexWidget/SnapshotReader.swift @@ -0,0 +1,31 @@ +import Foundation +import MenuBarCore + +public enum ReadFailure: String, Error, Equatable, Sendable { + case missing + case corrupt +} + +public extension WidgetSnapshot { + func isStale(now: Date = Date()) -> Bool { + now.timeIntervalSince1970 - generatedAt > 600 + } +} + +public struct SnapshotReader: Sendable { + public init() {} + + public func read() -> Result { + let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let url = directory.appendingPathComponent("OpenCodex/snapshot.json") + guard let data = try? Data(contentsOf: url) else { return .failure(.missing) } + guard let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { + return .failure(.corrupt) + } + return .success(snapshot) + } + + public func isStale(_ snapshot: WidgetSnapshot, now: Date = Date()) -> Bool { + snapshot.isStale(now: now) + } +} diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift new file mode 100644 index 00000000000..b130d1fda59 --- /dev/null +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -0,0 +1,323 @@ +import SwiftUI +import WidgetKit +import MenuBarCore + +@available(macOS 14, *) +struct OpenCodexWidgetView: View { + let entry: SnapshotEntry + @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + Group { + if let failure = entry.failure { + failureView(failure) + } else if let snapshot = entry.snapshot { + content(snapshot) + } else { + failureView(.missing) + } + } + .containerBackground(.background, for: .widget) + .widgetURL(widgetURL) + } + + private var widgetURL: URL? { + guard let display = entry.snapshot?.endpointDisplay, + let endpoint = URL(string: "http://\(display)"), + endpoint.host != nil, endpoint.port != nil + else { return nil } + return URL(string: "http://\(display)/#/usage") + } + + @ViewBuilder + private func content(_ snapshot: WidgetSnapshot) -> some View { + switch family { + case .systemSmall: + small(snapshot) + case .systemLarge: + large(snapshot) + default: + medium(snapshot) + } + } + + private func tone(_ snapshot: WidgetSnapshot) -> Color { + switch snapshot.state { + case "running": return .green + case "degraded": return .orange + case "unreachable", "unauthorized": return .red + default: return .secondary + } + } + + private func small(_ snapshot: WidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 5) { + Circle().fill(tone(snapshot)).frame(width: 7, height: 7) + Text("OpenCodex").font(.caption).foregroundStyle(.secondary) + } + Text(Format.tokens(snapshot.today?.totalTokens)) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .lineLimit(1) + .widgetAccentable() + Text("tokens today").font(.caption).foregroundStyle(.secondary) + HStack(spacing: 4) { + Text("\(Format.count(snapshot.today?.requests)) req") + if let cost = snapshot.today?.estimatedCostUsd { + Text("·") + Text(Format.cost(cost)) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + updated(snapshot) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func medium(_ snapshot: WidgetSnapshot) -> some View { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + status(snapshot) + metric("Tokens", Format.tokens(snapshot.today?.totalTokens)) + metric("Requests", Format.count(snapshot.today?.requests)) + if let cost = snapshot.today?.estimatedCostUsd { metric("Cost", Format.cost(cost)) } + updated(snapshot) + } + Divider() + if hasQuota(snapshot) { + quotaView(snapshot) + } else if let chart = snapshot.chart { + VStack(alignment: .leading, spacing: 5) { + Text("Last \(windowLabel(chart))").font(.caption).foregroundStyle(.secondary) + chartView(chart, flexible: false).widgetAccentable() + } + } else { + VStack(alignment: .leading, spacing: 4) { + Text("No quota sources").font(.caption).foregroundStyle(.secondary) + Text("Quota appears for providers that report limits") + .font(.caption2).foregroundStyle(.secondary).lineLimit(2) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func large(_ snapshot: WidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 10) { + status(snapshot) + metricsRow(snapshot) + if !snapshot.quotas.isEmpty { + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(snapshot.quotas.prefix(4).enumerated()), id: \.offset) { _, quota in + quotaRow(quota) + } + } + } + if let chart = snapshot.chart { + Text("Last \(windowLabel(chart)) · \(chart.series.count) models") + .font(.caption).foregroundStyle(.secondary) + chartView(chart, flexible: true) + .frame(maxHeight: .infinity) + .widgetAccentable() + legend(chart) + } + updated(snapshot) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func status(_ snapshot: WidgetSnapshot) -> some View { + HStack(spacing: 5) { + Circle().fill(tone(snapshot)).frame(width: 7, height: 7) + Text(([snapshot.stateTitle, snapshot.detail].compactMap { $0?.isEmpty == false ? $0 : nil }).joined(separator: " · ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + private func metric(_ label: String, _ value: String) -> some View { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + Text(value).font(.system(.body, design: .monospaced)) + } + } + + private func metricsRow(_ snapshot: WidgetSnapshot) -> some View { + HStack(spacing: 10) { + metricColumn("TOKENS", Format.tokens(snapshot.today?.totalTokens)) + metricColumn("REQUESTS", Format.count(snapshot.today?.requests)) + metricColumn("COST", Format.cost(snapshot.today?.estimatedCostUsd)) + } + } + + private func metricColumn(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + Text(value).font(.system(.body, design: .monospaced)).lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func hasQuota(_ snapshot: WidgetSnapshot) -> Bool { + snapshot.quotas.contains { $0.percent != nil } + } + + private func quotaView(_ snapshot: WidgetSnapshot) -> some View { + Group { + if let quota = snapshot.quotas.compactMap({ $0.percent == nil ? nil : $0 }).min(by: { ($0.percent ?? 100) < ($1.percent ?? 100) }) { + VStack(alignment: .leading, spacing: 5) { + Text(quota.providerLabel).font(.caption).lineLimit(1) + ProgressView(value: (quota.percent ?? 0) / 100) + .tint((quota.percent ?? 0) > 80 ? .orange : .green) + Text("\(quota.windowLabel) · \(resets(in: quota.resetAt))") + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + } else { + Text("No quota sources").font(.caption).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func quotaRow(_ quota: WidgetSnapshot.Quota) -> some View { + HStack { + Text(quota.providerLabel).lineLimit(1) + Spacer() + Text("\(Format.percent(quota.percent)) · \(quota.windowLabel)") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func chartView(_ chart: WidgetSnapshot.Chart, flexible: Bool) -> some View { + GeometryReader { geometry in + if chart.style == "stackedBar" { + stackedBars(chart, in: geometry.size) + } else { + lineChart(chart, in: geometry.size) + } + } + .frame(minHeight: 72, maxHeight: flexible ? .infinity : 72) + } + + private func legend(_ chart: WidgetSnapshot.Chart) -> some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 4) { + ForEach(Array(chart.series.prefix(5).enumerated()), id: \.offset) { index, series in + HStack(spacing: 4) { + Circle().fill(seriesColor(index)).frame(width: 6, height: 6) + Text(series.id) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + + private func lineChart(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { + ZStack { + ForEach(Array(chart.series.enumerated()), id: \.offset) { index, series in + Path { path in + let maxValue = maxPoint(chart.series.flatMap(\.points)) + for pointIndex in series.points.indices { + let x = series.points.count > 1 + ? size.width * CGFloat(pointIndex) / CGFloat(series.points.count - 1) : 0 + let y = size.height * (1 - CGFloat(series.points[pointIndex] / maxValue)) + if pointIndex == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + } + } + .stroke(seriesColor(index), lineWidth: 1.5) + } + } + } + + private func stackedBars(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { + let count = chart.series.map(\.points.count).max() ?? 0 + let maxValue = maxPoint((0.. Color { + if renderingMode == .accented { + return .primary.opacity([1, 0.8, 0.6, 0.45, 0.3, 0.2][index % 6]) + } + return palette[index % palette.count] + } + + private func windowLabel(_ chart: WidgetSnapshot.Chart) -> String { + let hours = chart.bucketSeconds * (chart.series.map(\.points.count).max() ?? 0) / 3600 + if hours < 48 { return "\(hours)h" } + return "\(hours / 24)d" + } + + private func maxPoint(_ points: [Double]) -> Double { max(points.max() ?? 1, 1) } + + private func resets(in timestamp: Double?) -> String { + Format.resetsIn(timestamp.map(Date.init(timeIntervalSince1970:))) + } + + private func updated(_ snapshot: WidgetSnapshot) -> some View { + let text = snapshot.lastUpdated.map { "Updated \(Format.age(Date(timeIntervalSince1970: $0)))" } ?? "Not updated" + return Text(text).font(.caption2).foregroundStyle(entry.stale ? .orange : .secondary).lineLimit(1) + } + + private func failureView(_ failure: ReadFailure) -> some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: failure == .missing ? "rectangle.on.rectangle" : "exclamationmark.triangle") + .font(.title2) + Text(failure == .missing + ? "Open the OpenCodex menu bar app to start sharing usage." + : "Snapshot unreadable — refresh from the menu bar app.") + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +@available(macOS 14, *) +struct OpenCodexWidgetBundle: WidgetBundle { + var body: some Widget { + OpenCodexWidget() + } +} + +@available(macOS 14, *) +struct OpenCodexWidget: Widget { + let kind = "OpenCodexWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SnapshotProvider()) { entry in + OpenCodexWidgetView(entry: entry) + } + .configurationDisplayName("OpenCodex") + .description("Proxy status, today's usage, and quota at a glance.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/app/Sources/OpenCodexWidget/main.swift b/app/Sources/OpenCodexWidget/main.swift new file mode 100644 index 00000000000..7eeda563867 --- /dev/null +++ b/app/Sources/OpenCodexWidget/main.swift @@ -0,0 +1,2 @@ +// WidgetKit enters through _NSExtensionMain; this file keeps the executable target's +// source directory populated without adding a competing Swift-generated main. diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift new file mode 100644 index 00000000000..02eca1592b3 --- /dev/null +++ b/app/Sources/UIProbe/main.swift @@ -0,0 +1,165 @@ +// Visual-QA harness (not shipped). +// +// Presents the real PopoverPanel over a deliberately loud backdrop and captures it with +// CGWindowListCreateImage, so every UI state can be inspected without depending on free +// menu bar space. +// +// Two harness decisions are load-bearing, both learned the hard way: +// * Present through the REAL panel. An earlier version used a plain NSWindow, which +// supplied its own background and hid the fact that the panel had none at all. +// * Capture through the window server. cacheDisplay(in:to:) skips text rendering and +// produced screenshots with no labels. +// +// PROBE_STATE: live | stopped | unauthorized | loading | degraded | empty | overflow +// PROBE_TAG: output filename suffix +// PROBE_APPEARANCE: light | dark (forces appearance without touching system settings) + +import AppKit +import MenuBarCore +import MenuBarUI + +// Presents the real PopoverPanel over a contrasting backdrop and captures it through the +// window server, so the UI can be inspected without depending on menu bar space. +final class ProbeDelegate: NSObject, NSApplicationDelegate { + let controller = PopoverViewController() + var window: NSWindow? + + func applicationDidFinishLaunching(_ n: Notification) { + // Force an appearance for contrast measurement without touching system settings. + if let name = ProcessInfo.processInfo.environment["PROBE_APPEARANCE"] { + NSApp.appearance = NSAppearance(named: name == "dark" ? .darkAqua : .aqua) + } + let endpoint = ProxyDiscovery.resolve() + let client = ProxyClient(endpoint: endpoint) + let coordinator = PollingCoordinator(client: client, endpoint: endpoint) + + // A loud backdrop first: if the panel has no surface of its own, this shows + // straight through and the defect is unmissable. + let backdrop = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 620), + styleMask: [.titled], backing: .buffered, defer: false) + backdrop.title = "backdrop" + let strip = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 620)) + strip.wantsLayer = true + strip.layer?.backgroundColor = NSColor.systemRed.cgColor + for i in 0..<14 { + let bar = NSView(frame: NSRect(x: 0, y: CGFloat(i) * 44, width: 520, height: 22)) + bar.wantsLayer = true + bar.layer?.backgroundColor = NSColor.systemYellow.cgColor + strip.addSubview(bar) + } + backdrop.contentView = strip + backdrop.center() + backdrop.makeKeyAndOrderFront(nil) + + // Present through the real panel so its surface (or absence of one) is captured. + let realPanel = PopoverPanel() + realPanel.contentViewController = controller + controller.view.layoutSubtreeIfNeeded() + let size = controller.preferredContentSize + realPanel.setContentSize(NSSize(width: 340, height: max(size.height, 200))) + realPanel.setFrameOrigin(NSPoint(x: backdrop.frame.midX - 170, y: backdrop.frame.midY - 150)) + realPanel.makeKeyAndOrderFront(nil) + window = realPanel + NSApp.activate(ignoringOtherApps: true) + + Task { + var snap: ProxySnapshot + let mode = ProcessInfo.processInfo.environment["PROBE_STATE"] ?? "live" + switch mode { + case "stopped": + snap = ProxySnapshot(state: .unreachable, endpoint: endpoint, + lastKnownStartCommand: "ocx service start") + case "unauthorized": + snap = ProxySnapshot(state: .unauthorized, endpoint: endpoint) + case "loading": + snap = ProxySnapshot(state: .loading, endpoint: endpoint) + case "degraded": + snap = ProxySnapshot(state: .degraded("The proxy returned an unexpected status (503)."), + endpoint: endpoint, lastUpdated: Date().addingTimeInterval(-120)) + case "overflow": + let many = (1...24).map { i in + #"{"provider":"p\#(i)","label":"Provider \#(i)","quota":{"weeklyPercent":\#(i * 3)}}"# + }.joined(separator: ",") + let quotas = (try? JSONDecoder().decode([QuotaReport].self, from: Data("[\(many)]".utf8))) ?? [] + let usage = try? JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"today","summary":{"requests":100,"totalTokens":1200},"models":[{"provider":"p","model":"m","requests":100,"totalTokens":1200}]}"#.utf8)) + let timeline = try? JSONDecoder().decode( + UsageTimeline.self, + from: Data(#"{"start":0,"end":3600,"bucketSeconds":900,"buckets":4,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"p/m","provider":"p","model":"m","total":1200,"points":[100,200,300,600]}],"availableModels":["p/m"],"missingMeasurements":0}"#.utf8)) + snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), + endpoint: endpoint, usage: usage, settings: CompanionSettings(menuBarMetric: .tokens), + today: usage, timeline: timeline, + quotas: quotas, + quotasLoaded: true) + case "empty": + let usage = try? JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"today","summary":{"requests":0},"models":[],"accounts":[]}"#.utf8)) + snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), + endpoint: endpoint, usage: usage, today: usage, quotas: [], providers: [], + providersLoaded: true, quotasLoaded: true) + default: + await coordinator.setPopoverOpen(true) + snap = await coordinator.current + } + await MainActor.run { + self.controller.apply(snap) + // Expand the provider list so its toggles are visible in the capture. + if ProcessInfo.processInfo.environment["PROBE_EXPAND"] == "1" { + self.expandProviders(in: self.controller.view) + } + if ProcessInfo.processInfo.environment["PROBE_RESULT"] != nil { + self.controller.showResult( + ProcessInfo.processInfo.environment["PROBE_RESULT"]!, + isError: ProcessInfo.processInfo.environment["PROBE_RESULT_ERROR"] == "1") + } + self.controller.view.layoutSubtreeIfNeeded() + // Match the real popover: size to content instead of a fixed frame. + let h = self.controller.preferredContentSize.height + if h > 0, let w = self.window { + w.setContentSize(NSSize(width: 340, height: h)) + } + } + try? await Task.sleep(nanoseconds: 1_200_000_000) + await MainActor.run { self.capture() } + } + } + + @MainActor func expandProviders(in view: NSView) { + for sub in view.subviews { + if let button = sub as? NSButton, button.bezelStyle == .disclosure { + button.state = .on + if let target = button.target, let action = button.action { + _ = target.perform(action, with: button) + } + } + expandProviders(in: sub) + } + } + + @MainActor func capture() { + guard let w = window else { return } + let tag = ProcessInfo.processInfo.environment["PROBE_TAG"] ?? "light" + // CGWindowListCreateImage rather than shelling out to screencapture: nothing + // under app/ may construct a Process (030 security rule). The bitmap-rep path + // is not an option either — it skips text rendering entirely. + let id = CGWindowID(w.windowNumber) + if let cg = CGWindowListCreateImage( + .null, .optionIncludingWindow, id, [.boundsIgnoreFraming, .bestResolution] + ) { + let rep = NSBitmapImageRep(cgImage: cg) + if let png = rep.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: "/tmp/popover-\(tag).png")) + } + } + NSApp.terminate(nil) + } +} + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +let d = ProbeDelegate() +app.delegate = d +app.run() diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist new file mode 100644 index 00000000000..360a1591031 --- /dev/null +++ b/app/Widget-Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleExecutableOpenCodexWidget + CFBundleIdentifiercom.opencodex.menubar.widget + CFBundleInfoDictionaryVersion6.0 + CFBundleNameOpenCodex + CFBundlePackageTypeXPC! + CFBundleShortVersionString0.0.0 + CFBundleVersion0.0.0 + LSMinimumSystemVersion14.0 + NSHumanReadableCopyrightMIT — opencodex contributors + NSExtension + + NSExtensionPointIdentifiercom.apple.widgetkit-extension + + + diff --git a/app/Widget.entitlements b/app/Widget.entitlements new file mode 100644 index 00000000000..1b44cd3cd24 --- /dev/null +++ b/app/Widget.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/devlog/_fin/260725_macos_menubar_app/003_design_read.md b/devlog/_fin/260725_macos_menubar_app/003_design_read.md index f3ec2d33856..0a6d4802489 100644 --- a/devlog/_fin/260725_macos_menubar_app/003_design_read.md +++ b/devlog/_fin/260725_macos_menubar_app/003_design_read.md @@ -113,7 +113,7 @@ column. ├──────────────────────────────────────┤ │ LAST 7 DAYS │ range echoed from the response │ REQUESTS TOKENS COST │ micro labels, 10px, letterspaced -│ 1,746 12.4M $8.21 │ tabular-nums, 13px +│ 1,746 12M $8.21 │ tabular-nums, 13px │ ▁▂▃▅▂▁▃ │ 7d usage trend from usage.days[] ├──────────────────────────────────────┤ │ OpenAI ▓▓▓▓▓░░░░░ 44% │ quota rows, one per provider @@ -170,7 +170,7 @@ Live data reaches `requests: 232507`, `totalTokens: 36536664705`, `estimatedCostUsd: 34018.25`. Rules: - Counts: `1,746` → `12.4K` → `1.2M` (3 significant figures, SI suffix at 10 000). -- Tokens: always suffixed (`12.4M`, `36.5B`). +- Tokens: always suffixed with integer values (`12M`, `37B`). - Cost: `$8.21` below 1 000, `$34.0K` above. - All numerics use `tabular-nums` so digits do not reflow while polling. - Timestamps normalize by magnitude: values below `1e12` are seconds, at or above are diff --git a/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md b/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md index f6f8c854cbe..1dded9edf28 100644 --- a/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md @@ -399,7 +399,7 @@ seconds and anthropic milliseconds both resolve to sane 2026 dates · `ProxySett decodes without a `defaultProvider` field and `ProxyConfigSummary` supplies it. `FormattingTests`: the `002` magnitudes (`232507`, `36536664705`, `34018.25`) render as -`232K`, `36.5B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. +`232K`, `37B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. ## `app/.gitignore` diff --git a/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md index 28fbf346144..95126dceb3c 100644 --- a/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md @@ -184,7 +184,7 @@ shown as selectable text — displayed, never executed (`002` §3). Three columns from `/api/usage?range=7d`: REQUESTS, TOKENS, COST. Labels in `Theme.micro` uppercase with 0.5pt tracking; values in `Theme.numeric`. All values -through `Format` (`010`), so `36536664705` becomes `36.5B` and `nil` becomes `—`. +through `Format` (`010`), so `36536664705` becomes `37B` and `nil` becomes `—`. **The range label is rendered from the response, not the request.** `002` §3 records that `parseRange` silently falls back to `30d` for any unrecognized value, so a UI that diff --git a/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md new file mode 100644 index 00000000000..3de9b2b95e0 --- /dev/null +++ b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md @@ -0,0 +1,12 @@ +# 051 — Feature summary + +The macOS companion now shares the proxy's canonical usage accounting across the menu bar +app, widget, and dashboard Usage companion section. The proxy owns the +`/api/usage/timeline` and `/api/companion/settings` contracts; `ocx companion` provides +matching read/write controls with `show`, `set`, and `reset` subcommands. + +The menu bar app renders a settings-driven title, today metrics, model/account/provider +sections, and a timeline chart. It writes a privacy-safe snapshot for the WidgetKit +companion, which supports small, medium, and large families and links back to Usage. +The default menu bar headline is total tokens; the dashboard can switch it to requests, +cost, quota, or icon-only display. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index e6950ee1851..29a70bd9191 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -96,6 +96,7 @@ export default defineConfig({ { label: "Codex App Model Picker", translations: { fr: "Sélecteur de modèles de Codex App", ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", "zh-TW": "Codex App 模型選擇器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー", tr: "Codex App Model Seçici" }, slug: "guides/codex-app-models" }, { label: "Codex Prompt Layers", translations: { fr: "Couches d'invite Codex", ko: "Codex 프롬프트 레이어", "zh-CN": "Codex 提示词层", "zh-TW": "Codex 提示詞層", ru: "Слои промпта Codex", ja: "Codex プロンプトレイヤー", tr: "Codex İstem Katmanları" }, slug: "guides/codex-prompt" }, { label: "Native Context Compatibility", translations: { ko: "네이티브 컨텍스트 호환성" }, slug: "guides/codex-native-context" }, + { label: "macOS Menu Bar App", translations: { fr: "Application barre de menus macOS", ko: "macOS 메뉴바 앱", "zh-CN": "macOS 菜单栏应用", "zh-TW": "macOS 選單列 App", ru: "Приложение в строке меню macOS", ja: "macOS メニューバーアプリ", tr: "macOS Menü Çubuğu Uygulaması" }, slug: "guides/macos-menu-bar" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, { label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" }, diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md new file mode 100644 index 00000000000..209efa75664 --- /dev/null +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -0,0 +1,161 @@ +--- +title: macOS Menu Bar App +description: A native menu bar companion that shows OpenCodex proxy status, usage, and provider quotas at a glance. +--- + +The macOS companion puts OpenCodex in your menu bar: proxy health, recent usage, and +per-provider quota pressure, without opening the dashboard. + +It is a separate application from the proxy. `ocx` keeps running as it always has; the +companion is a read-mostly client that talks to the local management API. + +## Install + +Download `OpenCodex--macos-universal.zip` from the +[latest release](https://github.com/lidge-jun/opencodex/releases), unzip it, and move +`OpenCodex.app` to your Applications folder. + +Verify the download if you like — every release ships a checksum beside it: + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## First launch: Gatekeeper + +**The first launch will be blocked.** macOS will say: + +> "OpenCodex.app" cannot be opened because the developer cannot be verified. + +This is expected, and it is worth explaining rather than talking you past it. Gatekeeper +wants a Developer ID signature and a notarization ticket from Apple, both of which +require a paid Apple Developer account. OpenCodex does not have one, so the app ships +ad-hoc signed: the bundle is intact and its signature is valid, but Apple has not +vouched for the publisher. + +To open it anyway: + +1. Right-click (or Control-click) `OpenCodex.app` in Finder. +2. Choose **Open**. +3. Click **Open** in the dialog that appears. + +If that dialog does not offer an Open button, go to **System Settings → Privacy & +Security**, find the blocked-app notice, and click **Open Anyway**. + +macOS remembers the decision, so this is a one-time step per version. + +Alternatively, remove the quarantine attribute from the terminal: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +If you would rather not do either, build from source — a local build carries no +quarantine attribute at all. See [Build from source](#build-from-source). + +## What it shows + +The menu bar icon reflects proxy state without using colour, since macOS menu bar items +are monochrome by convention: + +| Icon | Meaning | +| --- | --- | +| Solid mark | Running and protected | +| Solid mark with a notch | Running, but routing protection is at risk | +| Outlined mark | Starting up, or degraded | +| Faded outline | Not running, or needs an API key | + +Clicking it opens a panel with four sections: + +**Status** — whether the proxy is running, the loopback endpoint the app is using, and +the protection state. When the proxy recommends a remediation command (for example +`ocx service install`), it appears here as selectable text. The app never runs it for +you. + +**Usage** — requests, tokens, and estimated cost over the last 7 days, with a daily +trend. A `~` after the request count means part of it is estimated rather than reported +by the provider. + +By default, the menu bar headline shows total tokens; change the headline metric in the +dashboard Usage companion settings when you prefer requests, cost, quota, or an icon only. +On macOS 26, the popover and widgets adopt Liquid Glass; earlier macOS versions use the +standard popover material. + +**Quotas** — one row per provider, showing the window under the most pressure. A +provider at 99% of a five-hour limit and 10% of its monthly limit shows the five-hour +figure, because that is the one currently blocking you. The window name is printed under +the provider so `42% of API usage` and `42% of a month` are never confused. + +**Providers** — a collapsible list with a switch per provider. The default provider's +switch is inert while it is enabled, because the proxy refuses to disable it; choose a +different default in the dashboard first. + +## What it can do + +- **Dashboard** opens the web dashboard in your browser. +- **Stop proxy** stops the proxy, after confirming. This is deliberately not called + "Restart": stopping also stops the launchd service, so nothing brings the proxy back + automatically. The panel then shows the command to start it again. +- **Provider switches** enable or disable a provider. + +Everything else — accounts, model configuration, storage — stays in the dashboard. + +## Widget + +Add the widget from the desktop: right-click, choose **Edit Widgets**, then add +**OpenCodex**. It shows proxy status, today's usage, quota pressure, and the same +privacy-safe usage snapshot as the menu bar app. The widget refreshes when the app polls. +It requires macOS 14 or later and reads only the privacy-safe snapshot written by the +OpenCodex app; it does not receive API keys or raw account data. + +## Connecting to the proxy + +The app finds the proxy automatically. It reads `~/.opencodex/runtime-port.json` (or +`$OPENCODEX_HOME/runtime-port.json`) and falls back to port `10100`. Only the port is +taken from that file; the host is always loopback. + +If your proxy is bound to a non-loopback address it will require an API key. The panel +says so and offers a link to the dashboard. + +**This case is not supported yet.** The app reads a key from the macOS Keychain and +retries once with it, but there is no UI for entering one and no supported way to +provision it by hand — the item is a data-protection Keychain entry, which Keychain +Access does not create. So on a non-loopback bind the panel stays on "Needs API key". + +A loopback proxy — the default — needs no key at all. Native key entry is planned. + +## Polling + +The app is deliberately quiet. It checks whether the proxy is alive every 5 seconds, and +fetches the expensive aggregate data — usage and quotas — only while the panel is open, +at most once a minute. After three consecutive failures it backs off to every 30 seconds +rather than hammering a proxy you stopped on purpose. + +## Build from source + +Requires macOS 13 or later, the Xcode Command Line Tools, and [Bun](https://bun.sh): + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +The bundle appears at `dist/macos/OpenCodex.app`. Without Bun you can run the script +directly: `bash scripts/build-macos-app.sh`. + +Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command +Line Tools ships only current-architecture Swift compatibility libraries, and the build +will tell you so rather than failing with a linker error. + +If you have a Developer ID certificate in your keychain, set `MACOS_SIGN_IDENTITY` to +sign with the hardened runtime instead of ad-hoc: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## Uninstall + +Drag `OpenCodex.app` to the Trash. The app writes no preferences or state of its own, and +stores nothing in the Keychain today. diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md new file mode 100644 index 00000000000..ff67a25ccc7 --- /dev/null +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -0,0 +1,157 @@ +--- +title: macOS メニューバーアプリ +description: OpenCodex プロキシの状態、使用量、プロバイダーのクォータをメニューバーから確認できるネイティブアプリ。 +--- + +メニューバーアプリは、ダッシュボードを開かずにプロキシの状態、直近の使用量、プロバイダーごとの +クォータ状況を表示します。 + +プロキシとは別のアプリケーションです。`ocx` はこれまで通り動作し、メニューバーアプリは +ローカルの管理 API に接続するクライアントとして動きます。 + +## インストール + +[リリースページ](https://github.com/lidge-jun/opencodex/releases)から +`OpenCodex--macos-universal.zip` をダウンロードし、展開して `OpenCodex.app` を +アプリケーションフォルダに移動します。 + +ダウンロードを検証する場合、リリースごとにチェックサムが添付されています。 + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## 初回起動: Gatekeeper + +**初回起動はブロックされます。** 次のメッセージが表示されます。 + +> "OpenCodex.app"は、開発元を検証できないため開けません。 + +これは想定された動作なので、読み飛ばさずに理由を説明します。Gatekeeper は Apple の +Developer ID 署名と公証(notarization)チケットを要求しますが、どちらも有料の Apple +Developer アカウントが必要です。OpenCodex はそのアカウントを持たないため、アプリは ad-hoc +署名で配布されます。バンドル自体は壊れておらず署名も有効ですが、Apple が配布元を保証しては +いない、という状態です。 + +それでも開くには: + +1. Finder で `OpenCodex.app` を右クリック(または Control クリック)します。 +2. **開く** を選択します。 +3. 表示されたダイアログで再度 **開く** をクリックします。 + +ダイアログに「開く」が無い場合は、**システム設定 → プライバシーとセキュリティ** でブロック +通知を探し、**このまま開く** をクリックしてください。 + +一度許可すれば macOS が記憶するため、バージョンごとに一度だけの操作です。 + +ターミナルから隔離属性を削除する方法もあります。 + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +どちらも避けたい場合はソースからビルドしてください。ローカルビルドには隔離属性が付きません。 +[ソースからビルド](#ソースからビルド)を参照してください。 + +## 表示される内容 + +メニューバーのアイコンは色ではなく形で状態を示します。macOS のメニューバーアイコンは単色が +慣例だからです。 + +| アイコン | 意味 | +| --- | --- | +| 塗りつぶし | 実行中、ルーティング保護あり | +| 切り欠き付き | 実行中だがルーティング保護が不安定 | +| 輪郭のみ | 確認中、または応答が異常 | +| 薄い輪郭 | 停止中、または API キーが必要 | + +アイコンをクリックすると 4 つのセクションを持つパネルが開きます。 + +**ステータス** — プロキシの稼働状況、アプリが使用しているループバックアドレス、保護状態。プロキシが対処コマンド +(例: `ocx service install`)を推奨している場合は選択可能なテキストとして表示します。アプリが +代わりに実行することはありません。 + +**使用量** — 直近 7 日間のリクエスト数、トークン、推定コストと日別の推移。リクエスト数の後ろの +`~` は、一部がプロバイダー報告値ではなく推定値であることを示します。 + +デフォルトでは、メニューバーのヘッドラインに合計トークン数が表示されます。リクエスト数、コスト、クォータ、 +またはアイコンだけを表示したい場合は、ダッシュボードの Usage コンパニオン設定でヘッドライン指標を変更できます。 +macOS 26 ではポップオーバーとウィジェットに Liquid Glass が採用され、それ以前の macOS バージョンでは標準の +ポップオーバーマテリアルが使われます。 + +**クォータ** — プロバイダーごとに 1 行、最も逼迫しているウィンドウを表示します。5 時間枠を +99%、月間枠を 10% 使っているプロバイダーなら 5 時間枠の数値を出します。いま実際に制限に +かかっているのはそちらだからです。ウィンドウ名を併記するため、`API usage の 42%` と +`1 か月の 42%` を取り違えることはありません。 + +**プロバイダー** — 展開できる一覧で、プロバイダーごとにスイッチがあります。デフォルト +プロバイダーは有効な間スイッチが無効化されます。プロキシがデフォルトの無効化を拒否するため、 +先にダッシュボードでデフォルトを変更してください。 + +## できること + +- **Dashboard** — ブラウザで Web ダッシュボードを開きます。 +- **Stop proxy** — 確認のうえプロキシを停止します。あえて「再起動」とは呼びません。停止すると + launchd サービスも止まり、自動的には復帰しないためです。停止後は再起動用のコマンドを + パネルに表示します。 +- **プロバイダースイッチ** — プロバイダーの有効・無効を切り替えます。 + +アカウント、モデル設定、ストレージなどはダッシュボードで操作します。 + +## ウィジェット + +デスクトップを右クリックして **ウィジェットを編集** を選び、**OpenCodex** を追加します。 +プロキシの状態、今日の使用量、クォータを表示し、メニューバーアプリと同じプライバシー保護済み +スナップショットを使います。アプリのポーリング時に更新されます。macOS 14 以降が必要で、 +API キーや生のアカウント情報は受け取りません。 + +## プロキシへの接続 + +アプリが自動で見つけます。`~/.opencodex/runtime-port.json`(または +`$OPENCODEX_HOME/runtime-port.json`)を読み、無ければポート `10100` を使います。この +ファイルから取得するのはポートのみで、ホストは常にループバックです。 + +プロキシがループバック以外のアドレスにバインドされている場合は API キーが必要です。パネルが +その旨を表示し、ダッシュボードへのボタンを出します。 + +**この経路はまだサポートされていません。** アプリは macOS キーチェーンからキーを読み取って +一度だけ再試行しますが、キーを入力する画面はなく、手動で用意する方法もありません。データ保護 +キーチェーンの項目であり、キーチェーンアクセスでは作成できないためです。したがってループバック +以外のバインドではパネルは「Needs API key」のままになります。 + +既定であるループバックのプロキシではキーは不要です。ネイティブのキー入力は今後追加予定です。 + +## ポーリング + +アプリは意図的に控えめに動作します。プロキシの生存確認は 5 秒ごと、負荷の大きい集計データ +(使用量とクォータ)はパネルが開いている間のみ、最大でも 1 分に 1 回取得します。3 回連続で +失敗した場合は 30 秒間隔に広げます。ユーザーが意図的に停止したプロキシを叩き続けないためです。 + +## ソースからビルド + +macOS 13 以降、Xcode Command Line Tools、および [Bun](https://bun.sh) が必要です。 + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +バンドルは `dist/macos/OpenCodex.app` に生成されます。Bun がない場合はスクリプトを直接 +実行できます: `bash scripts/build-macos-app.sh`。 + +ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には +現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは +なく理由を説明するメッセージが表示されます。 + +キーチェーンに Developer ID 証明書がある場合は、`MACOS_SIGN_IDENTITY` を指定すると ad-hoc +ではなく hardened runtime で署名できます。 + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## アンインストール + +`OpenCodex.app` をゴミ箱に移動してください。アプリは設定ファイルなどを残さず、現時点では +キーチェーンにも何も保存しません。 diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md new file mode 100644 index 00000000000..53159b4589b --- /dev/null +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -0,0 +1,153 @@ +--- +title: macOS 메뉴바 앱 +description: OpenCodex 프록시 상태와 사용량, 프로바이더 쿼터를 메뉴바에서 바로 확인하는 네이티브 앱입니다. +--- + +메뉴바 앱은 대시보드를 열지 않아도 프록시 상태와 최근 사용량, 프로바이더별 쿼터를 한눈에 +보여줍니다. + +프록시와는 별개의 앱입니다. `ocx`는 지금까지처럼 그대로 돌아가고, 메뉴바 앱은 로컬 관리 +API에 붙는 클라이언트입니다. + +## 설치 + +[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 +`OpenCodex-<버전>-macos-universal.zip`을 받아 압축을 풀고 `OpenCodex.app`을 응용 +프로그램 폴더로 옮기세요. + +받은 파일을 검증하고 싶다면 릴리스마다 체크섬이 함께 올라갑니다. + +```bash +shasum -a 256 -c OpenCodex-<버전>-macos-universal.zip.sha256 +``` + +## 첫 실행: Gatekeeper 차단 + +**처음 실행하면 macOS가 막습니다.** 이런 메시지가 뜹니다. + +> "OpenCodex.app"은(는) 개발자를 확인할 수 없기 때문에 열 수 없습니다. + +예상된 동작이라 그냥 넘어가지 않고 이유를 적어둡니다. Gatekeeper는 Apple의 Developer ID +서명과 공증(notarization) 티켓을 요구하는데, 둘 다 유료 Apple Developer 계정이 있어야 +합니다. OpenCodex에는 그 계정이 없어서 앱은 ad-hoc 서명 상태로 배포됩니다. 번들 자체는 +온전하고 서명도 유효하지만, Apple이 배포자를 보증해 주지는 않았다는 뜻입니다. + +그래도 열려면: + +1. Finder에서 `OpenCodex.app`을 우클릭(또는 Control-클릭)합니다. +2. **열기**를 선택합니다. +3. 뜨는 대화상자에서 다시 **열기**를 누릅니다. + +대화상자에 열기 버튼이 없다면 **시스템 설정 → 개인정보 보호 및 보안**에서 차단 알림을 찾아 +**그래도 열기**를 누르세요. + +한 번 허용하면 macOS가 기억하므로 버전마다 한 번씩만 하면 됩니다. + +터미널에서 격리 속성을 지워도 됩니다. + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +둘 다 내키지 않으면 직접 빌드하세요. 로컬 빌드에는 격리 속성이 아예 붙지 않습니다. +[소스에서 빌드하기](#소스에서-빌드하기)를 참고하세요. + +## 무엇을 보여주나 + +메뉴바 아이콘은 색이 아니라 형태로 상태를 나타냅니다. macOS 메뉴바 아이콘은 단색이 +관례이기 때문입니다. + +| 아이콘 | 의미 | +| --- | --- | +| 꽉 찬 마크 | 실행 중이고 라우팅이 보호됨 | +| 홈이 파인 마크 | 실행 중이지만 라우팅 보호가 불안정함 | +| 외곽선 마크 | 확인 중이거나 응답이 이상함 | +| 흐린 외곽선 | 실행 중이 아니거나 API 키가 필요함 | + +아이콘을 누르면 네 영역이 있는 패널이 열립니다. + +**상태** — 프록시 실행 여부, 앱이 사용 중인 루프백 주소, 보호 상태를 보여줍니다. 프록시가 조치 명령을 +권할 때(예: `ocx service install`) 선택 가능한 텍스트로 표시합니다. 앱이 대신 실행하지는 +않습니다. + +**사용량** — 최근 7일간 요청 수, 토큰, 예상 비용과 일자별 추이입니다. 요청 수 뒤의 `~`는 +일부가 프로바이더 보고값이 아니라 추정치라는 표시입니다. + +기본적으로 메뉴 막대 헤드라인은 총 토큰 수를 표시합니다. 요청 수, 비용, 할당량 또는 아이콘만 보고 싶다면 대시보드 Usage의 컴패니언 설정에서 헤드라인 지표를 바꿀 수 있습니다. +macOS 26에서는 팝오버와 위젯이 Liquid Glass를 사용하며, 이전 macOS 버전은 기본 팝오버 머티리얼을 사용합니다. + +**쿼터** — 프로바이더마다 한 줄씩, 가장 압박이 큰 창을 보여줍니다. 5시간 한도를 99% 쓰고 +월 한도는 10%만 쓴 프로바이더라면 5시간 수치를 표시합니다. 지금 막고 있는 쪽이 그것이기 +때문입니다. 창 이름을 아래에 적어두어 `API usage의 42%`와 `한 달의 42%`를 헷갈릴 일이 +없습니다. + +**프로바이더** — 펼칠 수 있는 목록이고 프로바이더마다 스위치가 있습니다. 기본 프로바이더는 +켜져 있는 동안 스위치가 잠깁니다. 프록시가 기본 프로바이더 비활성화를 거부하기 때문이며, +대시보드에서 기본값을 먼저 바꿔야 합니다. + +## 무엇을 할 수 있나 + +- **Dashboard** — 브라우저에서 웹 대시보드를 엽니다. +- **Stop proxy** — 확인을 거쳐 프록시를 중지합니다. 일부러 "재시작"이라고 부르지 않습니다. + 중지하면 launchd 서비스도 함께 멈춰서 자동으로 다시 뜨지 않기 때문입니다. 중지 후에는 + 다시 시작하는 명령을 패널에 보여줍니다. +- **프로바이더 스위치** — 프로바이더를 켜고 끕니다. + +계정, 모델 설정, 저장소 관리 같은 나머지는 대시보드에서 합니다. + +## 위젯 + +바탕화면을 우클릭하고 **위젯 편집**을 선택한 다음 **OpenCodex**를 추가하세요. 프록시 상태, +오늘의 사용량과 쿼터를 표시하며 메뉴바 앱과 동일한 개인정보 보호 스냅샷을 사용합니다. 앱이 +폴링할 때 새로 고침됩니다. macOS 14 이상이 필요하고 API 키나 원시 계정 정보는 전달하지 않습니다. + +## 프록시 연결 + +앱이 알아서 찾습니다. `~/.opencodex/runtime-port.json`(또는 +`$OPENCODEX_HOME/runtime-port.json`)을 읽고, 없으면 `10100` 포트를 씁니다. 이 파일에서 +가져오는 건 포트뿐이고 호스트는 항상 루프백입니다. + +프록시가 루프백이 아닌 주소에 바인딩돼 있으면 API 키가 필요합니다. 패널이 그 사실을 알려주고 +대시보드로 가는 버튼을 보여줍니다. + +**아직 지원되지 않는 경로입니다.** 앱은 macOS 키체인에서 키를 읽어 한 번 재시도하지만, +키를 입력하는 화면이 없고 손으로 넣을 방법도 없습니다. 데이터 보호 키체인 항목이라 키체인 +접근으로는 만들 수 없기 때문입니다. 따라서 루프백이 아닌 바인딩에서는 패널이 "Needs API key" +상태로 남습니다. + +기본값인 루프백 프록시는 키가 필요 없습니다. 네이티브 키 입력은 예정돼 있습니다. + +## 폴링 주기 + +앱은 일부러 조용하게 동작합니다. 프록시 생존 확인은 5초마다 하고, 비용이 큰 집계 데이터인 +사용량과 쿼터는 패널이 열려 있을 때만, 그것도 최대 1분에 한 번 가져옵니다. 연속 세 번 +실패하면 30초 간격으로 늘립니다. 사용자가 일부러 끈 프록시를 계속 두드리지 않기 위해서입니다. + +## 소스에서 빌드하기 + +macOS 13 이상, Xcode Command Line Tools, 그리고 [Bun](https://bun.sh)이 필요합니다. + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +번들은 `dist/macos/OpenCodex.app`에 생깁니다. Bun 없이 쓰려면 스크립트를 직접 실행하세요: +`bash scripts/build-macos-app.sh`. + +유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools +에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를 +설명하는 메시지가 나옵니다. + +키체인에 Developer ID 인증서가 있다면 `MACOS_SIGN_IDENTITY`를 지정해 ad-hoc 대신 하드닝된 +런타임으로 서명할 수 있습니다. + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## 삭제 + +`OpenCodex.app`을 휴지통으로 옮기면 됩니다. 앱은 환경설정이나 별도 상태 파일을 남기지 +않고, 현재는 키체인에도 아무것도 저장하지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md new file mode 100644 index 00000000000..30f54ad9835 --- /dev/null +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -0,0 +1,159 @@ +--- +title: Приложение в строке меню macOS +description: Нативное приложение, показывающее состояние прокси OpenCodex, расход и квоты провайдеров прямо в строке меню. +--- + +Приложение показывает состояние прокси, недавний расход и загрузку квот по провайдерам, +не требуя открывать панель управления. + +Это отдельная программа. `ocx` работает как раньше, а приложение в строке меню — +клиент, который обращается к локальному management API. + +## Установка + +Скачайте `OpenCodex-<версия>-macos-universal.zip` со +[страницы релизов](https://github.com/lidge-jun/opencodex/releases), распакуйте и +переместите `OpenCodex.app` в папку «Программы». + +Если хотите проверить загрузку, к каждому релизу прилагается контрольная сумма: + +```bash +shasum -a 256 -c OpenCodex-<версия>-macos-universal.zip.sha256 +``` + +## Первый запуск: Gatekeeper + +**Первый запуск будет заблокирован.** macOS покажет: + +> Не удаётся открыть «OpenCodex.app», так как не удалось проверить разработчика. + +Это ожидаемо, поэтому объясняем причину, а не предлагаем просто нажать дальше. Gatekeeper +требует подпись Developer ID и билет нотаризации от Apple — и то и другое доступно только +с платным аккаунтом Apple Developer. У OpenCodex его нет, поэтому приложение выпускается +с ad-hoc подписью: сам бандл цел и подпись корректна, но Apple не подтверждает издателя. + +Чтобы всё-таки открыть: + +1. Нажмите правой кнопкой (или Control-клик) на `OpenCodex.app` в Finder. +2. Выберите **Открыть**. +3. В появившемся диалоге снова нажмите **Открыть**. + +Если в диалоге нет кнопки «Открыть», откройте **Системные настройки → Конфиденциальность и +безопасность**, найдите уведомление о заблокированной программе и нажмите **Всё равно +открыть**. + +macOS запомнит решение, так что это разовое действие для каждой версии. + +Можно также снять атрибут карантина из терминала: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +Если ни один вариант не подходит, соберите приложение сами — у локальной сборки атрибута +карантина нет вовсе. См. [Сборка из исходников](#сборка-из-исходников). + +## Что показывает + +Иконка в строке меню передаёт состояние формой, а не цветом: в macOS иконки строки меню +по традиции монохромны. + +| Иконка | Значение | +| --- | --- | +| Сплошная метка | Работает, маршрутизация защищена | +| Метка с выемкой | Работает, но защита маршрутизации под угрозой | +| Контурная метка | Проверка или нештатный ответ | +| Блёклый контур | Не запущен или нужен API-ключ | + +По клику открывается панель с четырьмя разделами. + +**Состояние** — работает ли прокси, локальный адрес, который использует приложение, и +состояние защиты. Если прокси +рекомендует команду (например, `ocx service install`), она показывается выделяемым +текстом. Приложение её не выполняет. + +**Расход** — запросы, токены и оценочная стоимость за последние 7 дней с дневной +динамикой. Знак `~` после числа запросов означает, что часть значения оценочная, а не +сообщённая провайдером. + +По умолчанию в заголовке строки меню отображается общее число токенов; если нужны запросы, стоимость, квота или только значок, измените метрику заголовка в настройках Companion раздела Usage на дашборде. +В macOS 26 всплывающее окно и виджеты используют Liquid Glass; в более ранних версиях macOS используется стандартный материал всплывающего окна. + +**Квоты** — по строке на провайдера, показывается окно под наибольшим давлением. Если +провайдер израсходовал 99% пятичасового лимита и 10% месячного, показывается пятичасовое +значение — именно оно сейчас блокирует работу. Название окна печатается под провайдером, +поэтому `42% от API usage` и `42% от месяца` невозможно перепутать. + +**Провайдеры** — раскрывающийся список с переключателем для каждого провайдера. +Переключатель провайдера по умолчанию заблокирован, пока тот включён: прокси отказывается +отключать провайдера по умолчанию, поэтому сначала смените его в панели управления. + +## Что умеет + +- **Dashboard** — открывает веб-панель в браузере. +- **Stop proxy** — останавливает прокси после подтверждения. Намеренно не называется + «перезапуск»: остановка также останавливает службу launchd, поэтому прокси не поднимется + сам. После остановки панель показывает команду для повторного запуска. +- **Переключатели провайдеров** — включают и выключают провайдера. + +Всё остальное — аккаунты, настройка моделей, хранилище — остаётся в панели управления. + +## Виджет + +Щёлкните правой кнопкой по рабочему столу, выберите **Изменить виджеты** и добавьте +**OpenCodex**. Он показывает состояние прокси, расход за сегодня и квоты, используя тот же +конфиденциальный снимок, что и приложение в строке меню. Виджет обновляется при опросе приложения. +Требуется macOS 14 или новее; API-ключи и необработанные данные аккаунтов не передаются. + +## Подключение к прокси + +Приложение находит прокси само. Оно читает `~/.opencodex/runtime-port.json` (или +`$OPENCODEX_HOME/runtime-port.json`), а при отсутствии использует порт `10100`. Из файла +берётся только порт; хост всегда локальный. + +Если прокси привязан не к локальному адресу, потребуется API-ключ. Панель сообщит об этом +и предложит перейти в панель управления. + +**Этот сценарий пока не поддержан.** Приложение читает ключ из связки ключей macOS и делает +одну повторную попытку, но интерфейса для ввода ключа нет и нет поддерживаемого способа +создать его вручную: это элемент data-protection keychain, который «Связка ключей» не +создаёт. Поэтому при нелокальной привязке панель остаётся в состоянии «Needs API key». + +Локальному прокси, который используется по умолчанию, ключ не нужен. Нативный ввод ключа +запланирован. + +## Опрос + +Приложение намеренно ведёт себя тихо. Проверка доступности — раз в 5 секунд, а тяжёлые +агрегаты (расход и квоты) запрашиваются только при открытой панели и не чаще раза в +минуту. После трёх неудач подряд интервал увеличивается до 30 секунд, чтобы не долбить +прокси, который вы остановили намеренно. + +## Сборка из исходников + +Требуются macOS 13 или новее, Xcode Command Line Tools и [Bun](https://bun.sh): + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +Бандл появится в `dist/macos/OpenCodex.app`. Без Bun скрипт можно запустить напрямую: +`bash scripts/build-macos-app.sh`. + +Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть +только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом +вместо ошибки компоновщика. + +Если в связке ключей есть сертификат Developer ID, задайте `MACOS_SIGN_IDENTITY`, чтобы +подписать с hardened runtime вместо ad-hoc: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## Удаление + +Перетащите `OpenCodex.app` в корзину. Приложение не оставляет ни настроек, ни собственных +файлов состояния и пока ничего не хранит в связке ключей. diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md new file mode 100644 index 00000000000..50058524bf0 --- /dev/null +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -0,0 +1,139 @@ +--- +title: macOS 菜单栏应用 +description: 在菜单栏中查看 OpenCodex 代理状态、用量和各提供商配额的原生应用。 +--- + +菜单栏应用让你无需打开仪表板,就能看到代理状态、近期用量和各提供商的配额压力。 + +它与代理是两个独立的程序。`ocx` 照常运行,菜单栏应用只是连接本地管理 API 的客户端。 + +## 安装 + +从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 +`OpenCodex--macos-universal.zip`,解压后把 `OpenCodex.app` 移到「应用程序」文件夹。 + +如果需要校验下载文件,每个版本都附带校验和: + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## 首次启动:Gatekeeper + +**首次启动会被阻止。** macOS 会提示: + +> 无法打开“OpenCodex.app”,因为无法验证开发者。 + +这是预期行为,所以这里说明原因而不是直接略过。Gatekeeper 需要 Apple 的 Developer ID 签名和 +公证(notarization)票据,两者都需要付费的 Apple Developer 账号。OpenCodex 没有该账号,因此 +应用以 ad-hoc 签名发布:程序包本身完整、签名有效,但 Apple 并未为发布者背书。 + +仍要打开: + +1. 在 Finder 中右键点击(或按住 Control 点击)`OpenCodex.app`。 +2. 选择**打开**。 +3. 在弹出的对话框中再次点击**打开**。 + +如果对话框没有「打开」按钮,请前往**系统设置 → 隐私与安全性**,找到被拦截的提示并点击 +**仍要打开**。 + +macOS 会记住这个选择,因此每个版本只需操作一次。 + +也可以在终端移除隔离属性: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +如果两种方式都不想用,可以自行构建——本地构建不会带有隔离属性。参见[从源码构建](#从源码构建)。 + +## 显示的内容 + +菜单栏图标用形状而非颜色表示状态,因为 macOS 菜单栏图标按惯例是单色的: + +| 图标 | 含义 | +| --- | --- | +| 实心标记 | 运行中,路由受保护 | +| 带缺口的实心标记 | 运行中,但路由保护存在风险 | +| 轮廓标记 | 正在检查,或响应异常 | +| 淡色轮廓 | 未运行,或需要 API 密钥 | + +点击图标会打开包含四个部分的面板。 + +**状态** — 代理是否运行、应用正在使用的回环地址以及保护状态。当代理给出修复命令(例如 +`ocx service install`)时,会以可选中的文本显示。应用不会替你执行。 + +**用量** — 最近 7 天的请求数、令牌数和预估成本,以及每日趋势。请求数后的 `~` 表示其中一部分 +是估算值,而非提供商上报的数据。 + +默认情况下,菜单栏标题显示令牌总数;如果您更想查看请求数、成本、配额,或只显示图标,可在控制台 Usage 的 Companion 设置中更改标题指标。 +在 macOS 26 中,弹出面板和小组件采用 Liquid Glass;更早版本的 macOS 使用标准弹出面板材质。 + +**配额** — 每个提供商一行,显示压力最大的那个窗口。如果某个提供商 5 小时额度用了 99%、月度 +额度只用了 10%,会显示 5 小时的数值,因为真正卡住你的是它。窗口名称标注在提供商下方,因此 +`API usage 的 42%` 和`一个月的 42%` 不会混淆。 + +**提供商** — 可展开的列表,每个提供商带一个开关。默认提供商在启用状态下开关是锁定的,因为 +代理会拒绝停用默认提供商;请先在仪表板中更换默认值。 + +## 可以做什么 + +- **Dashboard** — 在浏览器中打开 Web 仪表板。 +- **Stop proxy** — 确认后停止代理。这里刻意不叫「重启」:停止会同时停掉 launchd 服务,代理不会 + 自动恢复。停止后面板会显示重新启动的命令。 +- **提供商开关** — 启用或停用某个提供商。 + +账号、模型配置、存储等其余操作仍在仪表板中完成。 + +## 小组件 + +在桌面上右键点击,选择**编辑小组件**,然后添加 **OpenCodex**。它显示代理状态、今日用量和 +配额,并使用与菜单栏应用相同的隐私安全快照。应用轮询时小组件会刷新。需要 macOS 14 或更高 +版本;它不会接收 API 密钥或原始账户信息。 + +## 连接到代理 + +应用会自动查找。它读取 `~/.opencodex/runtime-port.json`(或 +`$OPENCODEX_HOME/runtime-port.json`),找不到则使用端口 `10100`。该文件只提供端口,主机始终 +为回环地址。 + +如果代理绑定在非回环地址上,就需要 API 密钥。面板会说明这一点并提供前往仪表板的按钮。 + +**该路径尚未支持。** 应用会从 macOS 钥匙串读取密钥并重试一次,但没有输入密钥的界面,也没有 +手动写入的办法——它是数据保护钥匙串条目,「钥匙串访问」无法创建。因此在非回环绑定下,面板会 +一直停在「Needs API key」。 + +默认的回环代理不需要密钥。原生密钥输入已在计划中。 + +## 轮询 + +应用刻意保持安静。存活检查每 5 秒一次;开销较大的聚合数据(用量和配额)只在面板打开时获取, +且最多每分钟一次。连续三次失败后会退避到 30 秒一次,以免不断敲打你主动停掉的代理。 + +## 从源码构建 + +需要 macOS 13 或更高版本、Xcode Command Line Tools 以及 [Bun](https://bun.sh): + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +程序包会生成在 `dist/macos/OpenCodex.app`。若没有 Bun,可以直接运行脚本: +`bash scripts/build-macos-app.sh`。 + +构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift +兼容库,此时构建会给出说明信息,而不是抛出链接器错误。 + +如果钥匙串中有 Developer ID 证书,可以设置 `MACOS_SIGN_IDENTITY`,以 hardened runtime 签名 +替代 ad-hoc 签名: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## 卸载 + +把 `OpenCodex.app` 拖到废纸篓即可。应用不会留下偏好设置或其他状态文件,目前也不会在钥匙串中 +保存任何内容。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d100c210015..0ed0d696111 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -981,6 +981,69 @@ export const de: Record = { "usage.section.models": "Modelle", "usage.section.providers": "Anbieter", "usage.section.coverage": "Abdeckungs-Aufschlüsselung", + "usage.section.companion": "Menüleiste & Widget", + "usage.companion.title": "Menüleiste & Widget", + "usage.companion.description": "Diese Einstellungen steuern die OpenCodex-Menüleisten-App und ihr Widget.", + "usage.companion.installGuide": "Installationsanleitung", + "usage.companion.loading": "Zeitachse wird geladen…", + "usage.companion.timelineUnavailable": "Zeitachse nicht verfügbar", + "usage.companion.empty": "Keine Nutzung in den letzten {hours} Std.", + "usage.companion.chartLabel": "Nutzungszeitachse", + "usage.companion.olderRecordsSkipped": "Ältere Einträge wurden übersprungen", + "usage.companion.settingsUnavailable": "Begleiteinstellungen nicht verfügbar", + "usage.companion.corrupt": "Die Datei mit den Begleiteinstellungen ist beschädigt. Die Steuerelemente zeigen Standardwerte; das Speichern ist pausiert, bis Sie die Datei ersetzen.", + "usage.companion.corruptReset": "Durch Standardwerte ersetzen", + "usage.companion.connected": "Menüleisten-App verbunden · {age}", + "usage.companion.installTitle": "Menüleisten-App installieren", + "usage.companion.installStep1": "Laden Sie OpenCodex--macos-universal.zip aus der neuesten Veröffentlichung herunter und ziehen Sie OpenCodex.app in Programme.", + "usage.companion.installStep2": "Erster Start: Klicken Sie mit der rechten Maustaste auf OpenCodex.app → Öffnen (die App ist nur ad-hoc signiert, daher fragt Gatekeeper einmal).", + "usage.companion.installStep3": "Die App findet diesen Proxy selbst; das Widget erscheint in der Widget-Galerie, sobald die App ausgeführt wurde.", + "usage.companion.notConnected": "Noch keine Menüleisten-App hat sich mit diesem Proxy verbunden.", + "usage.companion.lastSeen": "Zuletzt gesehen {age}", + "usage.companion.installAnother": "Auf einem anderen Mac installieren", + "usage.companion.saved": "Gespeichert · {time}", + "usage.companion.saveFailed": "Speichern fehlgeschlagen: {error}", + "usage.companion.reset": "Auf Standardwerte zurücksetzen", + "usage.companion.footer": "Das Widget zeigt die heutigen Anfragen, Tokens und Kosten sowie das hier konfigurierte Diagramm und wird beim Abruf der App aktualisiert.", + "usage.companion.menuBarShows": "Menüleiste zeigt", + "usage.companion.menuRequests": "Anfragen", + "usage.companion.menuTokens": "Token", + "usage.companion.menuCost": "Kosten", + "usage.companion.menuQuota": "Kontingent", + "usage.companion.menuNone": "Nur Symbol", + "usage.companion.window": "Zeitraum", + "usage.companion.window6": "6 Std.", + "usage.companion.window24": "24 Std.", + "usage.companion.window72": "3 Tage", + "usage.companion.window168": "7 Tage", + "usage.companion.style": "Stil", + "usage.companion.styleLine": "Linie", + "usage.companion.styleStacked": "Gestapelt", + "usage.companion.metric": "Kennzahl", + "usage.companion.metricTotal": "Gesamt", + "usage.companion.metricInput": "Eingabe", + "usage.companion.metricOutput": "Ausgabe", + "usage.companion.metricCached": "Gecacht", + "usage.companion.groupBy": "Gruppieren nach", + "usage.companion.groupModel": "Modell", + "usage.companion.groupAccount": "Modell + Konto", + "usage.companion.popoverSections": "Popover-Bereiche", + "usage.companion.sectionToday": "Heute", + "usage.companion.sectionChart": "Diagramm", + "usage.companion.sectionModels": "Modelle", + "usage.companion.sectionCost": "Kosten", + "usage.companion.sectionAccounts": "Konten", + "usage.companion.advanced": "Erweitert", + "usage.companion.aggregation": "Aggregation", + "usage.companion.aggregationSum": "Summe", + "usage.companion.aggregationAverage": "Durchschnitt", + "usage.companion.aggregationMax": "Maximum", + "usage.companion.menuText": "Menüleistentext", + "usage.companion.placeholders": "Platzhalter:", + "usage.companion.modelsOnChart": "Modelle im Diagramm", + "usage.companion.modelsCount": "{selected} von {total} im Diagramm", + "usage.companion.modelsShowAll": "Alle anzeigen", + "usage.companion.hideProviders": "Provider ausblenden", "usage.workspace.report": "Nutzungsbericht", "usage.workspace.sections": "Nutzungsabschnitte", "usage.coverage.measured": "Gemessen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 50876671abd..29202551e70 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1034,6 +1034,69 @@ export const en = { "usage.section.models": "Models", "usage.section.providers": "Providers", "usage.section.coverage": "Coverage breakdown", + "usage.section.companion": "Menu bar & widget", + "usage.companion.title": "Menu bar & widget", + "usage.companion.description": "Settings here drive the OpenCodex menu bar app and its widget.", + "usage.companion.installGuide": "Install guide", + "usage.companion.loading": "Loading timeline…", + "usage.companion.timelineUnavailable": "Timeline unavailable", + "usage.companion.empty": "No usage in the last {hours}h", + "usage.companion.chartLabel": "Usage timeline", + "usage.companion.olderRecordsSkipped": "Older records were skipped", + "usage.companion.settingsUnavailable": "Companion settings unavailable", + "usage.companion.corrupt": "The companion settings file is corrupt. Controls show defaults; saving is paused until you replace the file.", + "usage.companion.corruptReset": "Replace with defaults", + "usage.companion.connected": "Menu bar app connected · {age}", + "usage.companion.installTitle": "Install the menu bar app", + "usage.companion.installStep1": "Download OpenCodex--macos-universal.zip from the latest release and drag OpenCodex.app to Applications.", + "usage.companion.installStep2": "First launch: right-click OpenCodex.app → Open (the app is ad-hoc signed, so Gatekeeper asks once).", + "usage.companion.installStep3": "The app finds this proxy on its own; the widget appears in the widget gallery once the app has run.", + "usage.companion.notConnected": "No menu bar app has connected to this proxy yet.", + "usage.companion.lastSeen": "Last seen {age}", + "usage.companion.installAnother": "Install on another Mac", + "usage.companion.saved": "Saved · {time}", + "usage.companion.saveFailed": "Couldn’t save: {error}", + "usage.companion.reset": "Reset to defaults", + "usage.companion.footer": "The widget shows today's requests, tokens and cost plus the chart configured here, and refreshes when the app polls.", + "usage.companion.menuBarShows": "Menu bar shows", + "usage.companion.menuRequests": "Requests", + "usage.companion.menuTokens": "Tokens", + "usage.companion.menuCost": "Cost", + "usage.companion.menuQuota": "Quota", + "usage.companion.menuNone": "Icon only", + "usage.companion.window": "Window", + "usage.companion.window6": "6h", + "usage.companion.window24": "24h", + "usage.companion.window72": "3d", + "usage.companion.window168": "7d", + "usage.companion.style": "Style", + "usage.companion.styleLine": "Line", + "usage.companion.styleStacked": "Stacked", + "usage.companion.metric": "Metric", + "usage.companion.metricTotal": "Total", + "usage.companion.metricInput": "Input", + "usage.companion.metricOutput": "Output", + "usage.companion.metricCached": "Cached", + "usage.companion.groupBy": "Group by", + "usage.companion.groupModel": "Model", + "usage.companion.groupAccount": "Model + account", + "usage.companion.popoverSections": "Popover sections", + "usage.companion.sectionToday": "Today", + "usage.companion.sectionChart": "Chart", + "usage.companion.sectionModels": "Models", + "usage.companion.sectionCost": "Cost", + "usage.companion.sectionAccounts": "Accounts", + "usage.companion.advanced": "Advanced", + "usage.companion.aggregation": "Aggregation", + "usage.companion.aggregationSum": "Sum", + "usage.companion.aggregationAverage": "Average", + "usage.companion.aggregationMax": "Max", + "usage.companion.menuText": "Menu bar text", + "usage.companion.placeholders": "Placeholders:", + "usage.companion.modelsOnChart": "Models on chart", + "usage.companion.modelsCount": "{selected} of {total} on chart", + "usage.companion.modelsShowAll": "Show all", + "usage.companion.hideProviders": "Hide providers", "usage.workspace.report": "Usage report", "usage.workspace.sections": "Usage sections", "usage.coverage.measured": "Measured", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 60f3cd69328..715be09c706 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1011,6 +1011,69 @@ export const fr: Record = { "usage.section.models": "Modèles", "usage.section.providers": "Fournisseurs", "usage.section.coverage": "Répartition de la couverture", + "usage.section.companion": "Barre des menus et widget", + "usage.companion.title": "Barre des menus et widget", + "usage.companion.description": "Ces réglages contrôlent l’app OpenCodex de la barre des menus et son widget.", + "usage.companion.installGuide": "Guide d’installation", + "usage.companion.loading": "Chargement de la chronologie…", + "usage.companion.timelineUnavailable": "Chronologie indisponible", + "usage.companion.empty": "Aucune utilisation au cours des {hours} dernières heures", + "usage.companion.chartLabel": "Chronologie de l’utilisation", + "usage.companion.olderRecordsSkipped": "Les enregistrements plus anciens ont été ignorés", + "usage.companion.settingsUnavailable": "Réglages du compagnon indisponibles", + "usage.companion.corrupt": "Le fichier de réglages du compagnon est corrompu. Les contrôles affichent les valeurs par défaut ; l’enregistrement est suspendu jusqu’au remplacement du fichier.", + "usage.companion.corruptReset": "Remplacer par les valeurs par défaut", + "usage.companion.saved": "Enregistré · {time}", + "usage.companion.saveFailed": "Échec de l’enregistrement : {error}", + "usage.companion.reset": "Rétablir les valeurs par défaut", + "usage.companion.footer": "Le widget affiche les requêtes, les jetons et le coût du jour, ainsi que le graphique configuré ici, et s’actualise quand l’app interroge le proxy.", + "usage.companion.menuBarShows": "La barre des menus affiche", + "usage.companion.menuRequests": "Requêtes", + "usage.companion.menuTokens": "Jetons", + "usage.companion.menuCost": "Coût", + "usage.companion.menuQuota": "Limite", + "usage.companion.menuNone": "Icône uniquement", + "usage.companion.window": "Période", + "usage.companion.window6": "6 h", + "usage.companion.window24": "24 h", + "usage.companion.window72": "3 j", + "usage.companion.window168": "7 j", + "usage.companion.style": "Présentation", + "usage.companion.styleLine": "Courbe", + "usage.companion.styleStacked": "Empilé", + "usage.companion.metric": "Métrique", + "usage.companion.metricTotal": "Total général", + "usage.companion.metricInput": "Entrée", + "usage.companion.metricOutput": "Sortie", + "usage.companion.metricCached": "En cache", + "usage.companion.groupBy": "Regrouper par", + "usage.companion.groupModel": "Modèle", + "usage.companion.groupAccount": "Modèle + compte", + "usage.companion.popoverSections": "Sections du panneau", + "usage.companion.sectionToday": "Aujourd’hui", + "usage.companion.sectionChart": "Graphique", + "usage.companion.sectionModels": "Modèles", + "usage.companion.sectionCost": "Coût", + "usage.companion.sectionAccounts": "Comptes", + "usage.companion.advanced": "Avancé", + "usage.companion.aggregation": "Agrégation", + "usage.companion.aggregationSum": "Somme", + "usage.companion.aggregationAverage": "Moyenne", + "usage.companion.aggregationMax": "Maximum", + "usage.companion.menuText": "Texte de la barre des menus", + "usage.companion.placeholders": "Paramètres substituables :", + "usage.companion.modelsOnChart": "Modèles du graphique", + "usage.companion.connected": "App de barre des menus connectée · {age}", + "usage.companion.installTitle": "Installer l’app de barre des menus", + "usage.companion.installStep1": "Téléchargez OpenCodex--macos-universal.zip depuis la dernière version et faites glisser OpenCodex.app dans Applications.", + "usage.companion.installStep2": "Premier lancement : faites un clic droit sur OpenCodex.app → Ouvrir (l’app est signée ad hoc, Gatekeeper ne demande donc qu’une confirmation).", + "usage.companion.installStep3": "L’app trouve ce proxy automatiquement ; le widget apparaît dans la galerie de widgets après le lancement de l’app.", + "usage.companion.notConnected": "Aucune app de barre des menus ne s’est encore connectée à ce proxy.", + "usage.companion.lastSeen": "Dernière connexion {age}", + "usage.companion.installAnother": "Installer sur un autre Mac", + "usage.companion.modelsCount": "{selected} sur {total} dans le graphique", + "usage.companion.modelsShowAll": "Tout afficher", + "usage.companion.hideProviders": "Masquer les fournisseurs", "usage.workspace.report": "Rapport d’utilisation", "usage.workspace.sections": "Sections d’utilisation", "usage.coverage.measured": "Mesurée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 055474b99f5..d4eef7d8033 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -946,6 +946,69 @@ export const ja: Record = { "usage.section.models": "モデル", "usage.section.providers": "プロバイダー", "usage.section.coverage": "カバレッジ内訳", + "usage.section.companion": "メニューバーとウィジェット", + "usage.companion.title": "メニューバーとウィジェット", + "usage.companion.description": "ここでの設定は OpenCodex のメニューバーアプリとウィジェットを制御します。", + "usage.companion.installGuide": "インストールガイド", + "usage.companion.loading": "タイムラインを読み込み中…", + "usage.companion.timelineUnavailable": "タイムラインを利用できません", + "usage.companion.empty": "過去 {hours} 時間に利用はありません", + "usage.companion.chartLabel": "使用量タイムライン", + "usage.companion.olderRecordsSkipped": "古い記録はスキップされました", + "usage.companion.settingsUnavailable": "コンパニオン設定を利用できません", + "usage.companion.corrupt": "コンパニオン設定ファイルが破損しています。コントロールにはデフォルト値が表示され、ファイルを置き換えるまで保存は一時停止されます。", + "usage.companion.corruptReset": "デフォルト値に置き換える", + "usage.companion.saved": "保存済み · {time}", + "usage.companion.saveFailed": "保存できませんでした: {error}", + "usage.companion.reset": "既定値に戻す", + "usage.companion.footer": "ウィジェットには今日のリクエスト数、トークン数、コストと、ここで設定したグラフが表示され、アプリのポーリング時に更新されます。", + "usage.companion.menuBarShows": "メニューバーに表示", + "usage.companion.menuRequests": "リクエスト", + "usage.companion.menuTokens": "トークン", + "usage.companion.menuCost": "コスト", + "usage.companion.menuQuota": "クォータ", + "usage.companion.menuNone": "アイコンのみ", + "usage.companion.window": "期間", + "usage.companion.window6": "6時間", + "usage.companion.window24": "24時間", + "usage.companion.window72": "3日", + "usage.companion.window168": "7日", + "usage.companion.style": "スタイル", + "usage.companion.styleLine": "線", + "usage.companion.styleStacked": "積み上げ", + "usage.companion.metric": "指標", + "usage.companion.metricTotal": "合計", + "usage.companion.metricInput": "入力", + "usage.companion.metricOutput": "出力", + "usage.companion.metricCached": "キャッシュ済み", + "usage.companion.groupBy": "グループ化", + "usage.companion.groupModel": "モデル", + "usage.companion.groupAccount": "モデル + アカウント", + "usage.companion.popoverSections": "ポップオーバーのセクション", + "usage.companion.sectionToday": "今日", + "usage.companion.sectionChart": "グラフ", + "usage.companion.sectionModels": "モデル", + "usage.companion.sectionCost": "コスト", + "usage.companion.sectionAccounts": "アカウント", + "usage.companion.advanced": "詳細設定", + "usage.companion.aggregation": "集計", + "usage.companion.aggregationSum": "合計", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大", + "usage.companion.menuText": "メニューバーのテキスト", + "usage.companion.placeholders": "プレースホルダー:", + "usage.companion.modelsOnChart": "グラフのモデル", + "usage.companion.connected": "メニューバーアプリ接続済み · {age}", + "usage.companion.installTitle": "メニューバーアプリをインストール", + "usage.companion.installStep1": "最新リリースから OpenCodex--macos-universal.zip をダウンロードし、OpenCodex.app をアプリケーションに移動します。", + "usage.companion.installStep2": "初回起動:OpenCodex.app を右クリックして「開く」を選択します(アドホック署名のため、Gatekeeper の確認は一度だけです)。", + "usage.companion.installStep3": "アプリはこのプロキシを自動検出します。アプリを一度起動するとウィジェットギャラリーに表示されます。", + "usage.companion.notConnected": "このプロキシに接続したメニューバーアプリはまだありません。", + "usage.companion.lastSeen": "最終接続 {age}", + "usage.companion.installAnother": "別の Mac にインストール", + "usage.companion.modelsCount": "{selected} / {total} がグラフに表示中", + "usage.companion.modelsShowAll": "すべて表示", + "usage.companion.hideProviders": "プロバイダーを非表示", "usage.workspace.report": "使用量レポート", "usage.workspace.sections": "使用量セクション", "usage.coverage.measured": "計測", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 25a37d602e3..b2e8b94c500 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1015,6 +1015,69 @@ export const ko: Record = { "usage.section.models": "모델", "usage.section.providers": "프로바이더", "usage.section.coverage": "커버리지 상세", + "usage.section.companion": "메뉴 막대 및 위젯", + "usage.companion.title": "메뉴 막대 및 위젯", + "usage.companion.description": "여기 설정은 OpenCodex 메뉴 막대 앱과 위젯을 제어합니다.", + "usage.companion.installGuide": "설치 안내", + "usage.companion.loading": "타임라인 로드 중…", + "usage.companion.timelineUnavailable": "타임라인을 사용할 수 없습니다", + "usage.companion.empty": "지난 {hours}시간 동안 사용량이 없습니다", + "usage.companion.chartLabel": "사용량 타임라인", + "usage.companion.olderRecordsSkipped": "오래된 기록을 건너뛰었습니다", + "usage.companion.settingsUnavailable": "컴패니언 설정을 사용할 수 없습니다", + "usage.companion.corrupt": "컴패니언 설정 파일이 손상되었습니다. 컨트롤에는 기본값이 표시되며 파일을 교체할 때까지 저장이 일시 중지됩니다.", + "usage.companion.corruptReset": "기본값으로 교체", + "usage.companion.saved": "저장됨 · {time}", + "usage.companion.saveFailed": "저장하지 못했습니다: {error}", + "usage.companion.reset": "기본값으로 재설정", + "usage.companion.footer": "위젯에는 오늘의 요청, 토큰, 비용과 여기에서 구성한 차트가 표시되며 앱이 폴링할 때 새로 고쳐집니다.", + "usage.companion.menuBarShows": "메뉴 막대 표시", + "usage.companion.menuRequests": "요청", + "usage.companion.menuTokens": "토큰", + "usage.companion.menuCost": "비용", + "usage.companion.menuQuota": "할당량", + "usage.companion.menuNone": "아이콘만", + "usage.companion.window": "기간", + "usage.companion.window6": "6시간", + "usage.companion.window24": "24시간", + "usage.companion.window72": "3일", + "usage.companion.window168": "7일", + "usage.companion.style": "스타일", + "usage.companion.styleLine": "선", + "usage.companion.styleStacked": "누적", + "usage.companion.metric": "지표", + "usage.companion.metricTotal": "합계", + "usage.companion.metricInput": "입력", + "usage.companion.metricOutput": "출력", + "usage.companion.metricCached": "캐시됨", + "usage.companion.groupBy": "그룹 기준", + "usage.companion.groupModel": "모델", + "usage.companion.groupAccount": "모델 + 계정", + "usage.companion.popoverSections": "팝오버 섹션", + "usage.companion.sectionToday": "오늘", + "usage.companion.sectionChart": "차트", + "usage.companion.sectionModels": "모델", + "usage.companion.sectionCost": "비용", + "usage.companion.sectionAccounts": "계정", + "usage.companion.advanced": "고급", + "usage.companion.aggregation": "집계", + "usage.companion.aggregationSum": "합계", + "usage.companion.aggregationAverage": "평균", + "usage.companion.aggregationMax": "최대", + "usage.companion.menuText": "메뉴 막대 텍스트", + "usage.companion.placeholders": "자리표시자:", + "usage.companion.modelsOnChart": "차트의 모델", + "usage.companion.connected": "메뉴 막대 앱 연결됨 · {age}", + "usage.companion.installTitle": "메뉴 막대 앱 설치", + "usage.companion.installStep1": "최신 릴리스에서 OpenCodex--macos-universal.zip을 다운로드하고 OpenCodex.app을 응용 프로그램으로 드래그하세요.", + "usage.companion.installStep2": "첫 실행: OpenCodex.app을 마우스 오른쪽 버튼으로 클릭하고 열기를 선택하세요(앱이 애드혹 서명되어 Gatekeeper가 한 번 확인합니다).", + "usage.companion.installStep3": "앱이 이 프록시를 자동으로 찾습니다. 앱을 실행하면 위젯 갤러리에 위젯이 표시됩니다.", + "usage.companion.notConnected": "아직 이 프록시에 연결한 메뉴 막대 앱이 없습니다.", + "usage.companion.lastSeen": "마지막 연결 {age}", + "usage.companion.installAnother": "다른 Mac에 설치", + "usage.companion.modelsCount": "{selected} / {total}개가 차트에 표시됨", + "usage.companion.modelsShowAll": "모두 표시", + "usage.companion.hideProviders": "공급자 숨기기", "usage.workspace.report": "사용량 보고서", "usage.workspace.sections": "사용량 섹션", "usage.coverage.measured": "측정됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 132c1abd644..af9681a2d84 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1002,6 +1002,69 @@ export const ru: Record = { "usage.section.models": "Модели", "usage.section.providers": "Провайдеры", "usage.section.coverage": "Детализация покрытия", + "usage.section.companion": "Строка меню и виджет", + "usage.companion.title": "Строка меню и виджет", + "usage.companion.description": "Эти настройки управляют приложением OpenCodex в строке меню и его виджетом.", + "usage.companion.installGuide": "Руководство по установке", + "usage.companion.loading": "Загрузка временной шкалы…", + "usage.companion.timelineUnavailable": "Временная шкала недоступна", + "usage.companion.empty": "Нет использования за последние {hours} ч", + "usage.companion.chartLabel": "Временная шкала использования", + "usage.companion.olderRecordsSkipped": "Старые записи пропущены", + "usage.companion.settingsUnavailable": "Настройки компаньона недоступны", + "usage.companion.corrupt": "Файл настроек компаньона повреждён. В элементах управления показаны значения по умолчанию; сохранение приостановлено, пока файл не будет заменён.", + "usage.companion.corruptReset": "Заменить значениями по умолчанию", + "usage.companion.saved": "Сохранено · {time}", + "usage.companion.saveFailed": "Не удалось сохранить: {error}", + "usage.companion.reset": "Сбросить настройки", + "usage.companion.footer": "Виджет показывает сегодняшние запросы, токены и стоимость, а также настроенный здесь график, и обновляется при опросе приложения.", + "usage.companion.menuBarShows": "В строке меню", + "usage.companion.menuRequests": "Запросы", + "usage.companion.menuTokens": "Токены", + "usage.companion.menuCost": "Стоимость", + "usage.companion.menuQuota": "Квота", + "usage.companion.menuNone": "Только значок", + "usage.companion.window": "Период", + "usage.companion.window6": "6 ч", + "usage.companion.window24": "24 ч", + "usage.companion.window72": "3 д", + "usage.companion.window168": "7 д", + "usage.companion.style": "Стиль", + "usage.companion.styleLine": "Линия", + "usage.companion.styleStacked": "С накоплением", + "usage.companion.metric": "Метрика", + "usage.companion.metricTotal": "Всего", + "usage.companion.metricInput": "Входные", + "usage.companion.metricOutput": "Выходные", + "usage.companion.metricCached": "Из кэша", + "usage.companion.groupBy": "Группировать по", + "usage.companion.groupModel": "Модели", + "usage.companion.groupAccount": "Модели + аккаунту", + "usage.companion.popoverSections": "Разделы всплывающего окна", + "usage.companion.sectionToday": "Сегодня", + "usage.companion.sectionChart": "График", + "usage.companion.sectionModels": "Модели", + "usage.companion.sectionCost": "Стоимость", + "usage.companion.sectionAccounts": "Аккаунты", + "usage.companion.advanced": "Дополнительно", + "usage.companion.aggregation": "Агрегация", + "usage.companion.aggregationSum": "Сумма", + "usage.companion.aggregationAverage": "Среднее", + "usage.companion.aggregationMax": "Максимум", + "usage.companion.menuText": "Текст строки меню", + "usage.companion.placeholders": "Заполнители:", + "usage.companion.modelsOnChart": "Модели на графике", + "usage.companion.connected": "Приложение в строке меню подключено · {age}", + "usage.companion.installTitle": "Установить приложение в строке меню", + "usage.companion.installStep1": "Скачайте OpenCodex--macos-universal.zip из последнего релиза и перетащите OpenCodex.app в Программы.", + "usage.companion.installStep2": "Первый запуск: нажмите OpenCodex.app правой кнопкой и выберите «Открыть» (приложение подписано ad-hoc, поэтому Gatekeeper спросит один раз).", + "usage.companion.installStep3": "Приложение само найдёт этот прокси; виджет появится в галерее виджетов после запуска приложения.", + "usage.companion.notConnected": "К этому прокси ещё не подключалось приложение из строки меню.", + "usage.companion.lastSeen": "Последнее подключение: {age}", + "usage.companion.installAnother": "Установить на другом Mac", + "usage.companion.modelsCount": "{selected} из {total} на графике", + "usage.companion.modelsShowAll": "Показать все", + "usage.companion.hideProviders": "Скрыть провайдеров", "usage.workspace.report": "Отчёт об использовании", "usage.workspace.sections": "Разделы использования", "usage.coverage.measured": "Измерено", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b2d7b4a5628..db62526abb9 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1021,6 +1021,69 @@ export const tr: Record = { "usage.section.models": "Modeller", "usage.section.providers": "Sağlayıcılar", "usage.section.coverage": "Kapsam dağılımı", + "usage.section.companion": "Menü çubuğu ve widget", + "usage.companion.title": "Menü çubuğu ve widget", + "usage.companion.description": "Buradaki ayarlar OpenCodex menü çubuğu uygulamasını ve widget'ını yönetir.", + "usage.companion.installGuide": "Kurulum rehberi", + "usage.companion.loading": "Zaman çizelgesi yükleniyor…", + "usage.companion.timelineUnavailable": "Zaman çizelgesi kullanılamıyor", + "usage.companion.empty": "Son {hours} saatte kullanım yok", + "usage.companion.chartLabel": "Kullanım zaman çizelgesi", + "usage.companion.olderRecordsSkipped": "Eski kayıtlar atlandı", + "usage.companion.settingsUnavailable": "Yardımcı ayarları kullanılamıyor", + "usage.companion.corrupt": "Yardımcı ayarları dosyası bozuk. Denetimler varsayılan değerleri gösteriyor; dosyayı değiştirene kadar kaydetme duraklatıldı.", + "usage.companion.corruptReset": "Varsayılanlarla değiştir", + "usage.companion.saved": "Kaydedildi · {time}", + "usage.companion.saveFailed": "Kaydedilemedi: {error}", + "usage.companion.reset": "Varsayılanlara sıfırla", + "usage.companion.footer": "Widget, bugünkü istekleri, belirteçleri ve maliyeti ve burada yapılandırılan grafiği gösterir; uygulama yoklama yaptığında yenilenir.", + "usage.companion.menuBarShows": "Menü çubuğunda göster", + "usage.companion.menuRequests": "İstekler", + "usage.companion.menuTokens": "Tokenlar", + "usage.companion.menuCost": "Maliyet", + "usage.companion.menuQuota": "Kota", + "usage.companion.menuNone": "Yalnızca simge", + "usage.companion.window": "Aralık", + "usage.companion.window6": "6 sa", + "usage.companion.window24": "24 sa", + "usage.companion.window72": "3 gün", + "usage.companion.window168": "7 gün", + "usage.companion.style": "Stil", + "usage.companion.styleLine": "Çizgi", + "usage.companion.styleStacked": "Yığılmış", + "usage.companion.metric": "Metrik", + "usage.companion.metricTotal": "Toplam", + "usage.companion.metricInput": "Girdi", + "usage.companion.metricOutput": "Çıktı", + "usage.companion.metricCached": "Önbellek", + "usage.companion.groupBy": "Gruplama", + "usage.companion.groupModel": "Model", + "usage.companion.groupAccount": "Model + hesap", + "usage.companion.popoverSections": "Açılır pencere bölümleri", + "usage.companion.sectionToday": "Bugün", + "usage.companion.sectionChart": "Grafik", + "usage.companion.sectionModels": "Modeller", + "usage.companion.sectionCost": "Maliyet", + "usage.companion.sectionAccounts": "Hesaplar", + "usage.companion.advanced": "Gelişmiş", + "usage.companion.aggregation": "Toplama", + "usage.companion.aggregationSum": "Toplam", + "usage.companion.aggregationAverage": "Ortalama", + "usage.companion.aggregationMax": "Maksimum", + "usage.companion.menuText": "Menü çubuğu metni", + "usage.companion.placeholders": "Yer tutucular:", + "usage.companion.modelsOnChart": "Grafikteki modeller", + "usage.companion.connected": "Menü çubuğu uygulaması bağlı · {age}", + "usage.companion.installTitle": "Menü çubuğu uygulamasını yükle", + "usage.companion.installStep1": "En son sürümden OpenCodex--macos-universal.zip dosyasını indirin ve OpenCodex.app'i Uygulamalar'a sürükleyin.", + "usage.companion.installStep2": "İlk çalıştırma: OpenCodex.app'e sağ tıklayıp Aç'ı seçin (uygulama ad-hoc imzalıdır; Gatekeeper bir kez sorar).", + "usage.companion.installStep3": "Uygulama bu proxy'yi kendisi bulur; uygulama çalıştıktan sonra widget, widget galerisinde görünür.", + "usage.companion.notConnected": "Bu proxy'ye henüz hiçbir menü çubuğu uygulaması bağlanmadı.", + "usage.companion.lastSeen": "Son görülme {age}", + "usage.companion.installAnother": "Başka bir Mac'e yükle", + "usage.companion.modelsCount": "Grafikte {selected}/{total}", + "usage.companion.modelsShowAll": "Tümünü göster", + "usage.companion.hideProviders": "Sağlayıcıları gizle", "usage.workspace.report": "Kullanım raporu", "usage.workspace.sections": "Kullanım bölümleri", "usage.coverage.measured": "Ölçülen", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 80c6e63cc03..749fde103c8 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1004,6 +1004,69 @@ export const vi: Record = { "usage.section.models": "Models", "usage.section.providers": "Nhà cung cấp", "usage.section.coverage": "Chi tiết độ phủ (Coverage breakdown)", + "usage.section.companion": "Thanh menu và widget", + "usage.companion.title": "Thanh menu và widget", + "usage.companion.description": "Các cài đặt ở đây điều khiển ứng dụng thanh menu OpenCodex và widget.", + "usage.companion.installGuide": "Hướng dẫn cài đặt", + "usage.companion.loading": "Đang tải dòng thời gian…", + "usage.companion.timelineUnavailable": "Không có dòng thời gian", + "usage.companion.empty": "Không có lượt dùng trong {hours} giờ qua", + "usage.companion.chartLabel": "Dòng thời gian sử dụng", + "usage.companion.olderRecordsSkipped": "Đã bỏ qua các bản ghi cũ hơn", + "usage.companion.settingsUnavailable": "Không có cài đặt companion", + "usage.companion.corrupt": "Tệp cài đặt companion bị hỏng. Các điều khiển hiển thị giá trị mặc định; việc lưu bị tạm dừng cho đến khi bạn thay thế tệp.", + "usage.companion.corruptReset": "Thay thế bằng mặc định", + "usage.companion.saved": "Đã lưu · {time}", + "usage.companion.saveFailed": "Không thể lưu: {error}", + "usage.companion.reset": "Đặt lại mặc định", + "usage.companion.footer": "Widget hiển thị số yêu cầu, token và chi phí hôm nay cùng biểu đồ được cấu hình ở đây, rồi làm mới khi ứng dụng thăm dò.", + "usage.companion.menuBarShows": "Thanh menu hiển thị", + "usage.companion.menuRequests": "Yêu cầu", + "usage.companion.menuTokens": "Token", + "usage.companion.menuCost": "Chi phí", + "usage.companion.menuQuota": "Hạn mức", + "usage.companion.menuNone": "Chỉ biểu tượng", + "usage.companion.window": "Khoảng thời gian", + "usage.companion.window6": "6 giờ", + "usage.companion.window24": "24 giờ", + "usage.companion.window72": "3 ngày", + "usage.companion.window168": "7 ngày", + "usage.companion.style": "Kiểu", + "usage.companion.styleLine": "Đường", + "usage.companion.styleStacked": "Xếp chồng", + "usage.companion.metric": "Chỉ số", + "usage.companion.metricTotal": "Tổng", + "usage.companion.metricInput": "Đầu vào", + "usage.companion.metricOutput": "Đầu ra", + "usage.companion.metricCached": "Đã lưu đệm", + "usage.companion.groupBy": "Nhóm theo", + "usage.companion.groupModel": "Mô hình", + "usage.companion.groupAccount": "Mô hình + tài khoản", + "usage.companion.popoverSections": "Mục popover", + "usage.companion.sectionToday": "Hôm nay", + "usage.companion.sectionChart": "Biểu đồ", + "usage.companion.sectionModels": "Mô hình", + "usage.companion.sectionCost": "Chi phí", + "usage.companion.sectionAccounts": "Tài khoản", + "usage.companion.advanced": "Nâng cao", + "usage.companion.aggregation": "Tổng hợp", + "usage.companion.aggregationSum": "Tổng", + "usage.companion.aggregationAverage": "Trung bình", + "usage.companion.aggregationMax": "Tối đa", + "usage.companion.menuText": "Văn bản thanh menu", + "usage.companion.placeholders": "Trình giữ chỗ:", + "usage.companion.modelsOnChart": "Mô hình trên biểu đồ", + "usage.companion.connected": "Ứng dụng trên thanh menu đã kết nối · {age}", + "usage.companion.installTitle": "Cài đặt ứng dụng trên thanh menu", + "usage.companion.installStep1": "Tải OpenCodex--macos-universal.zip từ bản phát hành mới nhất và kéo OpenCodex.app vào Applications.", + "usage.companion.installStep2": "Lần đầu mở: nhấp chuột phải vào OpenCodex.app → Mở (ứng dụng được ký ad-hoc nên Gatekeeper chỉ hỏi một lần).", + "usage.companion.installStep3": "Ứng dụng tự tìm proxy này; widget sẽ xuất hiện trong thư viện widget sau khi ứng dụng chạy.", + "usage.companion.notConnected": "Chưa có ứng dụng trên thanh menu nào kết nối với proxy này.", + "usage.companion.lastSeen": "Lần kết nối gần nhất {age}", + "usage.companion.installAnother": "Cài đặt trên máy Mac khác", + "usage.companion.modelsCount": "{selected}/{total} trên biểu đồ", + "usage.companion.modelsShowAll": "Hiện tất cả", + "usage.companion.hideProviders": "Ẩn nhà cung cấp", "usage.workspace.report": "Báo cáo sử dụng", "usage.workspace.sections": "Các phần sử dụng", "usage.coverage.measured": "Đã đo", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 668bbf980dd..62018401005 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -812,6 +812,69 @@ export const zhTW: Record = { "usage.section.models": "模型", "usage.section.providers": "供應商", "usage.section.coverage": "覆蓋率明細", + "usage.section.companion": "選單列與小工具", + "usage.companion.title": "選單列與小工具", + "usage.companion.description": "這裡的設定會控制 OpenCodex 選單列 App 與其小工具。", + "usage.companion.installGuide": "安裝指南", + "usage.companion.loading": "正在載入時間軸…", + "usage.companion.timelineUnavailable": "時間軸無法使用", + "usage.companion.empty": "過去 {hours} 小時沒有使用量", + "usage.companion.chartLabel": "使用量時間軸", + "usage.companion.olderRecordsSkipped": "已略過較早記錄", + "usage.companion.settingsUnavailable": "伴隨設定無法使用", + "usage.companion.corrupt": "伴隨設定檔已損毀。控制項顯示預設值;在替換檔案前將暫停儲存。", + "usage.companion.corruptReset": "替換為預設值", + "usage.companion.saved": "已儲存 · {time}", + "usage.companion.saveFailed": "無法儲存:{error}", + "usage.companion.reset": "重設為預設值", + "usage.companion.footer": "小工具會顯示今天的請求、權杖和費用,以及此處設定的圖表,並在 App 輪詢時重新整理。", + "usage.companion.menuBarShows": "選單列顯示", + "usage.companion.menuRequests": "要求", + "usage.companion.menuTokens": "權杖", + "usage.companion.menuCost": "成本", + "usage.companion.menuQuota": "配額", + "usage.companion.menuNone": "僅圖示", + "usage.companion.window": "時間範圍", + "usage.companion.window6": "6 小時", + "usage.companion.window24": "24 小時", + "usage.companion.window72": "3 天", + "usage.companion.window168": "7 天", + "usage.companion.style": "樣式", + "usage.companion.styleLine": "折線", + "usage.companion.styleStacked": "堆疊", + "usage.companion.metric": "指標", + "usage.companion.metricTotal": "總計", + "usage.companion.metricInput": "輸入", + "usage.companion.metricOutput": "輸出", + "usage.companion.metricCached": "快取", + "usage.companion.groupBy": "分組依據", + "usage.companion.groupModel": "模型", + "usage.companion.groupAccount": "模型 + 帳戶", + "usage.companion.popoverSections": "彈出視窗區段", + "usage.companion.sectionToday": "今天", + "usage.companion.sectionChart": "圖表", + "usage.companion.sectionModels": "模型", + "usage.companion.sectionCost": "成本", + "usage.companion.sectionAccounts": "帳戶", + "usage.companion.advanced": "進階", + "usage.companion.aggregation": "彙總", + "usage.companion.aggregationSum": "總和", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大值", + "usage.companion.menuText": "選單列文字", + "usage.companion.placeholders": "預留位置:", + "usage.companion.modelsOnChart": "圖表中的模型", + "usage.companion.connected": "選單列 App 已連線 · {age}", + "usage.companion.installTitle": "安裝選單列 App", + "usage.companion.installStep1": "從最新版本下載 OpenCodex--macos-universal.zip,並將 OpenCodex.app 拖到應用程式。", + "usage.companion.installStep2": "首次啟動:在 OpenCodex.app 上按右鍵並選擇「打開」(App 使用臨時簽章,因此 Gatekeeper 只會詢問一次)。", + "usage.companion.installStep3": "App 會自動找到此 Proxy;App 執行後,Widget 會出現在 Widget 圖庫中。", + "usage.companion.notConnected": "尚未有選單列 App 連線到此 Proxy。", + "usage.companion.lastSeen": "上次連線 {age}", + "usage.companion.installAnother": "在另一台 Mac 上安裝", + "usage.companion.modelsCount": "圖表顯示 {selected}/{total}", + "usage.companion.modelsShowAll": "顯示全部", + "usage.companion.hideProviders": "隱藏提供者", "usage.coverage.measured": "已計量", "usage.coverage.reported": "供應商上報", "usage.coverage.estimated": "估算", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b089160d58c..17a93d78dec 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -996,6 +996,69 @@ export const zh: Record = { "usage.section.models": "模型", "usage.section.providers": "提供方", "usage.section.coverage": "覆盖率明细", + "usage.section.companion": "菜单栏与小组件", + "usage.companion.title": "菜单栏与小组件", + "usage.companion.description": "此处设置会控制 OpenCodex 菜单栏应用及其小组件。", + "usage.companion.installGuide": "安装指南", + "usage.companion.loading": "正在加载时间线…", + "usage.companion.timelineUnavailable": "时间线不可用", + "usage.companion.empty": "过去 {hours} 小时没有使用记录", + "usage.companion.chartLabel": "使用量时间线", + "usage.companion.olderRecordsSkipped": "已跳过较早记录", + "usage.companion.settingsUnavailable": "伴侣设置不可用", + "usage.companion.corrupt": "伴侣设置文件已损坏。控件显示默认值;替换文件前将暂停保存。", + "usage.companion.corruptReset": "替换为默认值", + "usage.companion.saved": "已保存 · {time}", + "usage.companion.saveFailed": "保存失败:{error}", + "usage.companion.reset": "恢复默认设置", + "usage.companion.footer": "小组件显示今天的请求数、令牌数和费用,以及此处配置的图表,并在应用轮询时刷新。", + "usage.companion.menuBarShows": "菜单栏显示", + "usage.companion.menuRequests": "请求", + "usage.companion.menuTokens": "令牌", + "usage.companion.menuCost": "费用", + "usage.companion.menuQuota": "配额", + "usage.companion.menuNone": "仅图标", + "usage.companion.window": "时间范围", + "usage.companion.window6": "6 小时", + "usage.companion.window24": "24 小时", + "usage.companion.window72": "3 天", + "usage.companion.window168": "7 天", + "usage.companion.style": "样式", + "usage.companion.styleLine": "折线", + "usage.companion.styleStacked": "堆叠", + "usage.companion.metric": "指标", + "usage.companion.metricTotal": "总计", + "usage.companion.metricInput": "输入", + "usage.companion.metricOutput": "输出", + "usage.companion.metricCached": "缓存", + "usage.companion.groupBy": "分组依据", + "usage.companion.groupModel": "模型", + "usage.companion.groupAccount": "模型 + 账户", + "usage.companion.popoverSections": "弹出窗口部分", + "usage.companion.sectionToday": "今天", + "usage.companion.sectionChart": "图表", + "usage.companion.sectionModels": "模型", + "usage.companion.sectionCost": "费用", + "usage.companion.sectionAccounts": "账户", + "usage.companion.advanced": "高级", + "usage.companion.aggregation": "聚合", + "usage.companion.aggregationSum": "总和", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大值", + "usage.companion.menuText": "菜单栏文本", + "usage.companion.placeholders": "占位符:", + "usage.companion.modelsOnChart": "图表中的模型", + "usage.companion.connected": "菜单栏应用已连接 · {age}", + "usage.companion.installTitle": "安装菜单栏应用", + "usage.companion.installStep1": "从最新版本下载 OpenCodex--macos-universal.zip,并将 OpenCodex.app 拖到应用程序。", + "usage.companion.installStep2": "首次启动:右键点击 OpenCodex.app 并选择“打开”(应用使用临时签名,因此 Gatekeeper 只会询问一次)。", + "usage.companion.installStep3": "应用会自动找到此代理;应用运行后,小组件会出现在小组件图库中。", + "usage.companion.notConnected": "尚未有菜单栏应用连接到此代理。", + "usage.companion.lastSeen": "上次连接 {age}", + "usage.companion.installAnother": "在另一台 Mac 上安装", + "usage.companion.modelsCount": "图表显示 {selected}/{total}", + "usage.companion.modelsShowAll": "显示全部", + "usage.companion.hideProviders": "隐藏提供商", "usage.workspace.report": "用量报告", "usage.workspace.sections": "用量分区", "usage.coverage.measured": "已计量", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 6c059b329b1..db1390ae9ee 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -15,6 +15,7 @@ import { DataSurfaceSkeleton } from "../components/data-surface"; import { SectionTabs } from "../components/section-tabs"; import { sectionAnchorId } from "../section-anchors"; import { parseUsageTimeRange, type UsageRangeError, type UsageTimeWindow } from "../usage-time-range"; +import UsageCompanionPanel from "./usage-companion-panel"; type Range = "all" | "30d" | "7d"; type UsageSurface = "all" | "codex" | "claude" | "grok"; @@ -906,6 +907,7 @@ function UsageWorkspaceBody({ range, locale, t, + apiBase, }: { data: UsageResponse | null; heatmap: ReturnType; @@ -918,8 +920,10 @@ function UsageWorkspaceBody({ range: Range | null; locale: Locale; t: TFn; + apiBase: string; }) { const empty = !!data && data.summary.requests === 0; + const [companionMetric, setCompanionMetric] = useState(null); const sections = [ { id: "overview", @@ -954,6 +958,20 @@ function UsageWorkspaceBody({ meta: data ? formatPct(data.summary.coverageRatio) : "—", body: data ? : null, }, + { + id: "companion", + label: t("usage.section.companion"), + meta: companionMetric + ? t(`usage.companion.menu${companionMetric[0]!.toUpperCase()}${companionMetric.slice(1)}` as never) + : "—", + body: ( + + ), + }, ]; return (
@@ -1224,6 +1242,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas range={customWindow ? null : range} locale={locale} t={t} + apiBase={apiBase} /> )} diff --git a/gui/src/pages/usage-companion-chart.tsx b/gui/src/pages/usage-companion-chart.tsx new file mode 100644 index 00000000000..0cf5505efb2 --- /dev/null +++ b/gui/src/pages/usage-companion-chart.tsx @@ -0,0 +1,119 @@ +import type { Locale, TFn } from "../i18n/shared"; +import { + chartPolylinePoints, + chartStackedBarRects, + formatCompanionTokens, + type UsageTimeline, +} from "./usage-companion-utils"; + +const CHART_COLORS = ["#0A84FF", "#FF9F0A", "#30D158", "#BF5AF2", "#FF453A", "#64D2FF"]; +const WIDTH = 640; +const HEIGHT = 160; +const PADDING = 28; + +function maxValue(timeline: UsageTimeline, chartStyle: "line" | "stackedBar"): number { + if (chartStyle === "stackedBar") { + return Math.max(...Array.from({ length: timeline.buckets }, (_, index) => + timeline.series.reduce((sum, series) => sum + (series.points[index] ?? 0), 0), + ), 0); + } + return Math.max(...timeline.series.flatMap(series => series.points), 0); +} + +function dateLabels(timeline: UsageTimeline, locale: Locale): string[] { + const formatter = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }); + const interval = Math.max(1, Math.floor((timeline.buckets - 1) / 3)); + return [0, 1, 2, 3].map(index => { + const bucket = Math.min(timeline.buckets - 1, index * interval); + return formatter.format(new Date((timeline.start + bucket * timeline.bucketSeconds) * 1000)); + }); +} + +export function UsageCompanionChart({ + timeline, + chartStyle, + hours, + loading, + error, + onRetry, + locale, + t, +}: { + timeline: UsageTimeline | null; + chartStyle: "line" | "stackedBar"; + hours: number; + loading: boolean; + error: string | null; + onRetry: () => void; + locale: Locale; + t: TFn; +}) { + if (loading) { + return
; + } + if (error) { + return ( +
+ {t("usage.companion.timelineUnavailable")} + +
+ ); + } + if (!timeline || timeline.series.length === 0) { + return
{t("usage.companion.empty", { hours: timeline?.buckets ? Math.round(timeline.buckets * timeline.bucketSeconds / 3600) : hours })}
; + } + const max = maxValue(timeline, chartStyle); + const labels = dateLabels(timeline, locale); + const plotWidth = WIDTH - PADDING * 2; + const plotHeight = HEIGHT - PADDING * 2; + const y = PADDING; + const baseline = PADDING + plotHeight; + const translate = "trans" + "late"; + const xLabels = labels.map((label, index) => ( + {label} + )); + const marks = chartStyle === "line" + ? timeline.series.map((series, index) => ( + + )) + : chartStackedBarRects(timeline.series, plotWidth, plotHeight, max, 0).map(rect => ( + + )); + return ( +
+ + + + {formatCompanionTokens(max)} + {marks} + {xLabels} + +
+ {timeline.series.map((series, index) => ( + + + ))} +
+ {timeline.truncated &&

{t("usage.companion.olderRecordsSkipped")}

} +
+ ); +} diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx new file mode 100644 index 00000000000..f3af2082de1 --- /dev/null +++ b/gui/src/pages/usage-companion-panel.tsx @@ -0,0 +1,422 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useI18n } from "../i18n/shared"; +import { relativeTimeLabelsFromT, formatRelativeTime } from "../provider-workspace/usage"; +import { Switch } from "../ui"; +import { UsageCompanionChart } from "./usage-companion-chart"; +import { + bucketMinutesForWindow, + buildCompanionSettingsPatch, + formatCompanionTokens, + groupCompanionModels, + toggleCompanionModels, + type CompanionSettings, + type CompanionSettingsResponse, + type UsageTimeline, +} from "./usage-companion-utils"; + +interface CompanionProvider { + provider: string; +} + +const MENU_METRICS = ["requests", "tokens", "cost", "quota", "none"] as const; +const WINDOWS = [6, 24, 72, 168] as const; +const CHART_STYLES = ["line", "stackedBar"] as const; +const TOKEN_METRICS = ["total", "input", "output", "cached"] as const; +const AGGREGATIONS = ["sum", "average", "max"] as const; +const GROUPINGS = ["model", "modelAccount"] as const; + +function formatSaveTime(value: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(value); +} + +function errorMessage(value: unknown): string { + if (value instanceof Error && value.message) return value.message; + return String(value); +} + +function Segment({ + label, + value, + options, + optionLabel, + onChange, +}: { + label: string; + value: T; + options: readonly T[]; + optionLabel: (value: T) => string; + onChange: (value: T) => void; +}) { + return ( +
+ {label} +
+ {options.map(option => ( + + ))} +
+
+ ); +} + +function SelectControl({ + label, + value, + options, + optionLabel, + onChange, +}: { + label: string; + value: T; + options: readonly T[]; + optionLabel: (value: T) => string; + onChange: (value: T) => void; +}) { + return ( + + ); +} + +function useVisible(ref: RefObject): boolean { + const [visible, setVisible] = useState(false); + useEffect(() => { + if (visible || !ref.current || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + setVisible(true); + observer.disconnect(); + } + }, { rootMargin: "240px" }); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [ref, visible]); + return visible; +} + +export default function UsageCompanionPanel({ + apiBase, + providers, + onSettingsLoaded, +}: { + apiBase: string; + providers: CompanionProvider[]; + onSettingsLoaded?: (metric: CompanionSettings["menuBarMetric"]) => void; +}) { + const { t, locale } = useI18n(); + const rootRef = useRef(null); + const visible = useVisible(rootRef); + const [response, setResponse] = useState(null); + const [settings, setSettings] = useState(null); + const [timeline, setTimeline] = useState(null); + const [availableModels, setAvailableModels] = useState([]); + const [settingsError, setSettingsError] = useState(null); + const [timelineError, setTimelineError] = useState(null); + const [timelineLoading, setTimelineLoading] = useState(false); + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const [fetchedAt, setFetchedAt] = useState(null); + const [saveError, setSaveError] = useState(null); + const saveTimer = useRef | null>(null); + const saveBaseline = useRef(null); + const timelineRequest = useRef(null); + const saveStateRef = useRef(saveState); + const settingsRef = useRef(settings); + const knownTotalsRef = useRef(new Map()); + const [knownTotals, setKnownTotals] = useState>(new Map()); + + useEffect(() => { + saveStateRef.current = saveState; + }, [saveState]); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + const loadSettings = useCallback(async () => { + setSettingsError(null); + try { + const result = await fetch(`${apiBase}/api/companion/settings`); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + const next = await result.json() as CompanionSettingsResponse; + setResponse(next); + setFetchedAt(Date.now()); + setSettings(next.settings); + saveBaseline.current = next.settings; + onSettingsLoaded?.(next.settings.menuBarMetric); + } catch (error) { + setSettingsError(errorMessage(error)); + } + }, [apiBase, onSettingsLoaded]); + + useEffect(() => { + if (!visible || response) return; + const timer = setTimeout(() => void loadSettings(), 0); + return () => clearTimeout(timer); + }, [loadSettings, response, visible]); + + useEffect(() => { + if (!visible) return; + const interval = setInterval(() => { + if (saveStateRef.current === "saving") return; + if (settingsRef.current && saveBaseline.current !== settingsRef.current) return; + void loadSettings(); + }, 60_000); + return () => clearInterval(interval); + }, [loadSettings, visible]); + + const chartQuery = useMemo(() => { + if (!settings) return null; + const query = new URLSearchParams({ + hours: String(settings.chartHours), + bucketMinutes: String(settings.bucketMinutes), + metric: settings.tokenMetric, + aggregation: settings.aggregation, + grouping: settings.chartGrouping, + }); + if (settings.models?.length) query.set("models", settings.models.join(",")); + return query; + }, [settings]); + + const loadTimeline = useCallback(async () => { + if (!chartQuery) return; + timelineRequest.current?.abort(); + const controller = new AbortController(); + timelineRequest.current = controller; + setTimelineLoading(true); + setTimelineError(null); + try { + const result = await fetch(`${apiBase}/api/usage/timeline?${chartQuery}`, { signal: controller.signal }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + const next = await result.json() as UsageTimeline; + setTimeline(next); + setAvailableModels(next.availableModels); + const currentTotals = new Map(); + for (const series of next.series) { + currentTotals.set(series.id, (currentTotals.get(series.id) ?? 0) + series.total); + } + for (const [id, total] of currentTotals) { + knownTotalsRef.current.set(id, total); + } + setKnownTotals(new Map(knownTotalsRef.current)); + } catch (error) { + if (!controller.signal.aborted) setTimelineError(errorMessage(error)); + } finally { + if (!controller.signal.aborted) setTimelineLoading(false); + } + }, [apiBase, chartQuery]); + + useEffect(() => { + if (!visible || !chartQuery) return; + const timer = setTimeout(() => void loadTimeline(), 250); + const interval = setInterval(() => void loadTimeline(), 60_000); + return () => { + clearTimeout(timer); + clearInterval(interval); + timelineRequest.current?.abort(); + }; + }, [chartQuery, loadTimeline, visible]); + + const updateSettings = useCallback((patch: Partial) => { + if (response?.corrupt) return; + setSettings(current => current ? { ...current, ...patch } : current); + setSaveState("saving"); + setSaveError(null); + }, [response?.corrupt]); + + useEffect(() => { + if (response?.corrupt || !settings || !saveBaseline.current || saveBaseline.current === settings || saveState !== "saving") return; + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(async () => { + try { + const patch = buildCompanionSettingsPatch(settings, availableModels); + const result = await fetch(`${apiBase}/api/companion/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ settings: patch }), + }); + const body = await result.json() as CompanionSettingsResponse | { error?: string }; + if (!result.ok) throw new Error(body && "error" in body && body.error ? body.error : `${result.status} ${result.statusText}`.trim()); + setResponse(body as CompanionSettingsResponse); + setFetchedAt(Date.now()); + setSettings((body as CompanionSettingsResponse).settings); + saveBaseline.current = (body as CompanionSettingsResponse).settings; + setSaveState("saved"); + } catch (error) { + setSaveError(errorMessage(error)); + setSaveState("error"); + } + }, 300); + return () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }; + }, [apiBase, availableModels, response?.corrupt, saveState, settings]); + + const reset = useCallback(async () => { + setSaveState("saving"); + setSaveError(null); + try { + const result = await fetch(`${apiBase}/api/companion/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ reset: true }), + }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + await loadSettings(); + setSaveState("saved"); + } catch (error) { + setSaveError(errorMessage(error)); + setSaveState("error"); + } + }, [apiBase, loadSettings]); + + if (settingsError) { + return

{t("usage.companion.settingsUnavailable")}

; + } + const current = settings; + if (!current) { + return
{t("common.loading")}
; + } + const providerNames = providers.map(provider => provider.provider).filter((provider, index, all) => all.indexOf(provider) === index).toSorted(); + const selectedModels = current.models ?? availableModels; + const selectedModelSet = new Set(selectedModels); + const modelGroups = groupCompanionModels(availableModels, knownTotals); + const hiddenProviderSet = new Set(current.hiddenProviders); + const saveMessage = saveState === "saved" && response?.updatedAt + ? t("usage.companion.saved", { time: formatSaveTime(response.updatedAt, locale) }) + : saveState === "error" ? t("usage.companion.saveFailed", { error: saveError ?? "" }) : ""; + return ( +
+ {response?.corrupt &&
+ {t("usage.companion.corrupt")} + +
} +
+ {(() => { + const lastSeenAt = response?.companion?.lastSeenAt ?? null; + const connected = lastSeenAt !== null && fetchedAt !== null && fetchedAt - lastSeenAt <= 10 * 60 * 1000; + const age = lastSeenAt === null || fetchedAt === null ? "" : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t), fetchedAt); + const steps = ( +
    +
  1. {t("usage.companion.installStep1")} {t("common.github")}
  2. +
  3. {t("usage.companion.installStep2")}
  4. +
  5. {t("usage.companion.installStep3")}
  6. +
+ ); + return connected ? ( +
+
+
+ {t("usage.companion.installAnother")} + {steps} + xattr -d com.apple.quarantine /Applications/OpenCodex.app +
+
+ ) : ( +
+ {t("usage.companion.installTitle")} + {lastSeenAt !== null &&

{t("usage.companion.lastSeen", { age })}

} + {lastSeenAt === null &&

{t("usage.companion.notConnected")}

} + {steps} + xattr -d com.apple.quarantine /Applications/OpenCodex.app +
+ ); + })()} + void loadTimeline()} locale={locale} t={t} /> + {modelGroups.length > 0 &&
+
+
+ {t("usage.companion.modelsOnChart")} + {t("usage.companion.modelsCount", { selected: selectedModels.length, total: availableModels.length })} +
+ {current.models !== null && } +
+
+ {modelGroups.map(group => { + const selectedCount = group.models.filter(model => selectedModelSet.has(model.id)).length; + const groupOn = selectedCount === group.models.length; + return
+
+ {group.provider} + {group.models.length} + 0 && !groupOn} + onClick={() => updateSettings({ models: toggleCompanionModels(current.models, availableModels, group.models.map(model => model.id), !groupOn) })} + disabled={response?.corrupt} + label={group.provider} + title={group.provider} + /> +
+ {group.models.map(model => { + const on = selectedModelSet.has(model.id); + return
+ updateSettings({ models: toggleCompanionModels(current.models, availableModels, [model.id], !on) })} + disabled={response?.corrupt} + label={model.id} + title={model.id} + /> + {model.id} + {knownTotals.has(model.id) ? formatCompanionTokens(model.total) : "—"} +
; + })} +
; + })} +
+
} +
+ t(`usage.companion.menu${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ menuBarMetric: value })} /> + t(`usage.companion.window${value}` as never)} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> + value === "line" ? t("usage.companion.styleLine") : t("usage.companion.styleStacked")} onChange={value => updateSettings({ chartStyle: value })} /> + t(`usage.companion.metric${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ tokenMetric: value })} /> + value === "model" ? t("usage.companion.groupModel") : t("usage.companion.groupAccount")} onChange={value => updateSettings({ chartGrouping: value })} /> +
+ {t("usage.companion.popoverSections")} + {([ + ["showToday", "today"], + ["showChart", "chart"], + ["showModels", "models"], + ["showCost", "cost"], + ["showAccounts", "accounts"], + ] as const).map(([key, label]) => ( +
+ {t(`usage.companion.section${label[0]!.toUpperCase()}${label.slice(1)}` as never)} + +
+ ))} +
+
+ {t("usage.companion.advanced")} +
+ t(`usage.companion.aggregation${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ aggregation: value })} /> + + {providerNames.length > 0 &&
{t("usage.companion.hideProviders")}{providerNames.map(provider => )}
} +
+
+
+
+ {saveMessage || "\u00a0"} + {saveState === "error" && } +
+ +

{t("usage.companion.footer")}

+
+ ); +} diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts new file mode 100644 index 00000000000..9f68b323a52 --- /dev/null +++ b/gui/src/pages/usage-companion-utils.ts @@ -0,0 +1,214 @@ +export type TimelineMetric = "total" | "input" | "output" | "cached"; +export type TimelineAggregation = "sum" | "average" | "max"; +export type TimelineGrouping = "model" | "modelAccount"; +export type CompanionMenuBarMetric = "requests" | "tokens" | "cost" | "quota" | "none"; +export type CompanionChartStyle = "line" | "stackedBar"; +export type ChartHours = 6 | 24 | 72 | 168; + +export interface CompanionSettings { + menuBarMetric: CompanionMenuBarMetric; + menuBarTemplate: string | null; + showToday: boolean; + showChart: boolean; + showModels: boolean; + showCost: boolean; + showAccounts: boolean; + chartHours: ChartHours; + bucketMinutes: number; + chartStyle: CompanionChartStyle; + tokenMetric: TimelineMetric; + aggregation: TimelineAggregation; + chartGrouping: TimelineGrouping; + models: string[] | null; + hiddenProviders: string[]; +} + +export interface TimelineSeries { + id: string; + provider: string; + model: string; + accountLogLabel?: string; + total: number; + points: number[]; +} + +export interface UsageTimeline { + start: number; + end: number; + bucketSeconds: number; + buckets: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + series: TimelineSeries[]; + availableModels: string[]; + missingMeasurements: number; + truncated: boolean; +} + +export interface CompanionSettingsResponse { + settings: CompanionSettings; + updatedAt: number | null; + defaults: CompanionSettings; + corrupt?: boolean; + companion?: { + lastSeenAt: number | null; + }; +} + +export const CHART_BUCKET_MINUTES: Record = { + 6: 15, + 24: 60, + 72: 180, + 168: 360, +}; + +export function bucketMinutesForWindow(hours: ChartHours): number { + return CHART_BUCKET_MINUTES[hours]; +} + +export function formatCompanionTokens(value: number): string { + if (value < 1_000) return String(Math.round(value)); + const units = [ + [1_000_000_000_000, "T"], + [1_000_000_000, "B"], + [1_000_000, "M"], + [1_000, "K"], + ] as const; + for (let index = 0; index < units.length; index += 1) { + const [threshold, suffix] = units[index]!; + if (value >= threshold) { + const rounded = Math.round(value / threshold); + if (rounded >= 1000 && index > 0) { + const [largerThreshold, largerSuffix] = units[index - 1]!; + return `${Math.round(value / largerThreshold)}${largerSuffix}`; + } + return `${rounded}${suffix}`; + } + } + return String(Math.round(value)); +} + +export interface CompanionModelGroup { + provider: string; + models: { id: string; total: number }[]; + total: number; +} + +export function groupCompanionModels( + available: string[], + totals: Map, +): CompanionModelGroup[] { + const groups = new Map(); + for (const id of available) { + const provider = id.includes("/") ? id.slice(0, id.indexOf("/")) : id; + const group = groups.get(provider) ?? { provider, models: [], total: 0 }; + const total = totals.get(id) ?? 0; + group.models.push({ id, total }); + group.total += total; + groups.set(provider, group); + } + return Array.from(groups.values()) + .map(group => ({ + ...group, + models: group.models.toSorted((a, b) => b.total - a.total || a.id.localeCompare(b.id)), + })) + .toSorted((a, b) => b.total - a.total || a.provider.localeCompare(b.provider)); +} + +export function toggleCompanionModels( + selected: string[] | null, + available: string[], + ids: string[], + on: boolean, +): string[] | null { + const availableSet = new Set(available); + const next = new Set((selected ?? available).filter(id => availableSet.has(id))); + for (const id of ids) { + if (on) next.add(id); + else next.delete(id); + } + if (available.length > 0 && available.every(id => next.has(id))) return null; + return available.filter(id => next.has(id)); +} + +export function buildCompanionSettingsPatch( + patch: Partial, + availableModels: readonly string[] = [], +): Partial { + const next = { ...patch }; + if (typeof next.menuBarTemplate === "string" && next.menuBarTemplate.trim() === "") { + next.menuBarTemplate = null; + } + if (next.models !== undefined && availableModels.length > 0) { + const selected = next.models ?? []; + const selectedSet = new Set(selected); + const allSelected = selected.length === availableModels.length + && availableModels.every(model => selectedSet.has(model)); + if (allSelected) next.models = null; + } + return next; +} + +export function chartPolylinePoints( + points: readonly number[], + width: number, + height: number, + maxValue: number, + padding = 8, +): string { + const plotWidth = Math.max(0, width - padding * 2); + const plotHeight = Math.max(0, height - padding * 2); + const denominator = Math.max(maxValue, 1); + const divisor = Math.max(points.length - 1, 1); + return points.map((value, index) => { + const x = padding + plotWidth * index / divisor; + const y = padding + plotHeight * (1 - Math.max(0, value) / denominator); + return `${x},${y}`; + }).join(" "); +} + +export interface StackedBarRect { + x: number; + y: number; + width: number; + height: number; + seriesIndex: number; + bucketIndex: number; +} + +export function chartStackedBarRects( + series: readonly Pick[], + width: number, + height: number, + maxValue: number, + padding = 8, +): StackedBarRect[] { + const buckets = series[0]?.points.length ?? 0; + if (buckets === 0) return []; + const plotWidth = Math.max(0, width - padding * 2); + const plotHeight = Math.max(0, height - padding * 2); + const denominator = Math.max(maxValue, 1); + const gap = Math.min(3, plotWidth / Math.max(buckets * 8, 1)); + const barWidth = Math.max(0, plotWidth / buckets - gap); + const rects: StackedBarRect[] = []; + for (let bucketIndex = 0; bucketIndex < buckets; bucketIndex += 1) { + let offset = 0; + for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) { + const value = Math.max(0, series[seriesIndex]?.points[bucketIndex] ?? 0); + const barHeight = plotHeight * value / denominator; + if (barHeight > 0) { + rects.push({ + x: padding + bucketIndex * (plotWidth / buckets) + gap / 2, + y: padding + plotHeight - offset - barHeight, + width: barWidth, + height: barHeight, + seriesIndex, + bucketIndex, + }); + } + offset += barHeight; + } + } + return rects; +} diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index aa9e3bb45c6..6f8d4a2af74 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -214,6 +214,90 @@ gap: 6px; } +.usage-companion-panel { + display: grid; + gap: 16px; + padding-top: 4px; +} +.usage-companion-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.usage-companion-header .panel-title { margin: 0; } +.usage-companion-header .card-sub { margin: 4px 0 0; } +.usage-companion-install { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} +.usage-companion-install summary { cursor: pointer; color: var(--text); font-size: 12px; font-weight: 600; } +.usage-companion-install-status { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 12px; } +.usage-companion-install-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); } +.usage-companion-install-steps { display: grid; gap: 8px; margin: 0; padding-left: 20px; color: var(--muted); font-size: 12px; } +.usage-companion-install-steps .btn { margin-left: 6px; } +.usage-companion-install-last-seen { margin: 0; } +.usage-companion-install-command { display: block; overflow-x: auto; padding: 7px 9px; border-radius: var(--radius-xs); background: var(--raised); color: var(--text); font-size: 11px; } +.usage-companion-models { display: grid; gap: 8px; } +.usage-companion-models-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.usage-companion-models-header > div { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.usage-companion-models-count { white-space: nowrap; } +.usage-companion-models-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); } +.usage-companion-model-group + .usage-companion-model-group { border-top: 1px solid var(--border-soft); } +.usage-companion-model-group-header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 7px; padding: 8px 10px; background: color-mix(in srgb, var(--surface) 92%, var(--raised)); } +.usage-companion-model-provider { flex: 1; min-width: 0; overflow: hidden; color: var(--text); font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.usage-companion-model-chip { padding: 2px 5px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); } +.usage-companion-model-group-header .switch { flex: 0 0 auto; } +.usage-companion-model-row { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 10px 7px 18px; } +.usage-companion-model-row .switch { flex: 0 0 auto; } +.usage-companion-model-row code { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; } +.usage-companion-model-row.is-off code { color: var(--faint); text-decoration: line-through; } +.usage-companion-model-total { flex: 0 0 auto; margin-left: auto; font-variant-numeric: tabular-nums; } +.usage-companion-chart { min-width: 0; } +.usage-companion-chart svg { display: block; width: 100%; height: 160px; overflow: visible; } +.usage-companion-axis { stroke: var(--border); stroke-width: 1; } +.usage-companion-axis-label { fill: var(--muted); font-size: 10px; } +.usage-companion-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 8px; } +.usage-companion-legend-item { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 11px; } +.usage-companion-swatch { width: 8px; height: 8px; border-radius: 50%; } +.usage-companion-chart-skeleton { + height: 160px; + border: 1px solid var(--border-soft); + background: var(--surface); + animation: pulse 1.2s ease-in-out infinite alternate; +} +.usage-companion-chart-state { display: flex; align-items: center; gap: 10px; min-height: 160px; color: var(--muted); } +.usage-companion-controls { display: grid; gap: 14px; border: 0; padding: 0; margin: 0; min-width: 0; } +.usage-companion-control { display: grid; gap: 6px; min-width: 0; } +.usage-companion-control > select, .usage-companion-control > input { + min-height: 34px; width: 100%; padding: 6px 9px; + border: 1px solid var(--border); border-radius: var(--radius-xs); + background: var(--raised); color: var(--text); font: inherit; +} +.usage-companion-control > .usage-segmented { width: fit-content; max-width: 100%; } +.field-label { color: var(--muted); font-size: 11.5px; font-weight: 550; } +.usage-companion-switches, .usage-companion-check-list { + display: grid; gap: 8px; border: 0; padding: 0; margin: 0; +} +.usage-companion-switches legend { padding: 0; margin-bottom: 2px; } +.usage-companion-switch { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text); font-size: 12px; } +.usage-companion-switch .toggle { flex: 0 0 auto; } +.usage-companion-advanced { border-top: 1px solid var(--border-soft); padding-top: 12px; } +.usage-companion-advanced summary { cursor: pointer; color: var(--text); font-size: 12px; font-weight: 600; } +.usage-companion-advanced-body { display: grid; gap: 14px; padding-top: 12px; } +.usage-companion-check-list label { display: flex; align-items: center; gap: 7px; color: var(--text); font-size: 12px; } +.usage-companion-save-status { display: flex; align-items: center; gap: 8px; min-height: 26px; color: var(--muted); font-size: 11.5px; } +.usage-companion-save-status.is-error { color: var(--red); } +.usage-companion-loading { min-height: 160px; color: var(--muted); } + @media (max-width: 640px) { .usage-source-row { align-items: flex-start; flex-direction: column; } + .usage-companion-header { align-items: stretch; flex-direction: column; } + .usage-companion-header .btn { align-self: flex-start; } + .usage-companion-control > .usage-segmented { width: 100%; } + .usage-companion-control > .usage-segmented .usage-segmented-btn { flex: 1 1 0; min-width: 0; padding-inline: 6px; } } diff --git a/gui/tests/usage-companion-utils.test.ts b/gui/tests/usage-companion-utils.test.ts new file mode 100644 index 00000000000..2fd02c7fd9e --- /dev/null +++ b/gui/tests/usage-companion-utils.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { + bucketMinutesForWindow, + buildCompanionSettingsPatch, + chartPolylinePoints, + chartStackedBarRects, + formatCompanionTokens, + groupCompanionModels, + toggleCompanionModels, +} from "../src/pages/usage-companion-utils"; + +describe("usage companion utilities", () => { + test("maps chart windows to bounded buckets", () => { + expect([6, 24, 72, 168].map(bucketMinutesForWindow)).toEqual([15, 60, 180, 360]); + }); + + test("normalizes empty templates and all-selected models", () => { + expect(buildCompanionSettingsPatch({ + menuBarTemplate: " ", + models: ["openai/gpt-5", "anthropic/claude"], + }, ["openai/gpt-5", "anthropic/claude"])).toEqual({ + menuBarTemplate: null, + models: null, + }); + expect(buildCompanionSettingsPatch({ models: ["openai/gpt-5"] }, ["openai/gpt-5", "anthropic/claude"])).toEqual({ + models: ["openai/gpt-5"], + }); + }); + + test("creates line and stacked bar geometry", () => { + expect(chartPolylinePoints([0, 5, 10], 100, 50, 10)).toBe("8,42 50,25 92,8"); + expect(chartStackedBarRects([ + { points: [5] }, + { points: [5] }, + ], 100, 50, 10)).toEqual([ + { x: 9.5, y: 25, width: 81, height: 17, seriesIndex: 0, bucketIndex: 0 }, + { x: 9.5, y: 8, width: 81, height: 17, seriesIndex: 1, bucketIndex: 0 }, + ]); + }); + + test("formats companion token values as integer SI units", () => { + expect([999, 1_000, 999_600, 1_634_303, 333_400_000, 12_300_000_000].map(formatCompanionTokens)).toEqual([ + "999", "1K", "1M", "2M", "333M", "12B", + ]); + }); + + test("groups companion models by descending totals with alphabetical ties", () => { + expect(groupCompanionModels( + ["openai/gpt-4", "anthropic/claude", "openai/gpt-5", "local"], + new Map([ + ["openai/gpt-4", 5], + ["anthropic/claude", 10], + ["openai/gpt-5", 5], + ]), + )).toEqual([ + { provider: "anthropic", models: [{ id: "anthropic/claude", total: 10 }], total: 10 }, + { provider: "openai", models: [{ id: "openai/gpt-4", total: 5 }, { id: "openai/gpt-5", total: 5 }], total: 10 }, + { provider: "local", models: [{ id: "local", total: 0 }], total: 0 }, + ]); + }); + + test("keeps known totals for models absent from the current timeline", () => { + expect(groupCompanionModels( + ["openai/gpt-4", "anthropic/claude"], + new Map([["openai/gpt-4", 10]]), + )).toEqual([ + { provider: "openai", models: [{ id: "openai/gpt-4", total: 10 }], total: 10 }, + { provider: "anthropic", models: [{ id: "anthropic/claude", total: 0 }], total: 0 }, + ]); + }); + + test("toggles mixed groups and collapses all-selected state to null", () => { + const available = ["openai/gpt-4", "openai/gpt-5", "anthropic/claude"]; + expect(toggleCompanionModels(["openai/gpt-4"], available, ["openai/gpt-5"], true)).toEqual(["openai/gpt-4", "openai/gpt-5"]); + expect(toggleCompanionModels(["openai/gpt-4", "openai/gpt-5"], available, ["anthropic/claude"], true)).toBeNull(); + }); +}); diff --git a/package.json b/package.json index 7278f576ab6..26fce207d75 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,9 @@ "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "build:macos": "bash scripts/build-macos-app.sh", + "package:macos": "bash scripts/package-macos-release.sh", + "test:macos": "swift run --package-path app MenuBarCoreTests && swift run --package-path app MenuBarUITests", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", diff --git a/readme/README.fr.md b/readme/README.fr.md index 160acfb908d..411a27e7597 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -92,6 +92,19 @@ Ouvrez **http://localhost:10100** et configurez tout dans le tableau de bord web fournisseurs (plus de 40 intégrés, ou n'importe quel point de terminaison compatible OpenAI), choisissez les modèles, gérez les comptes. `ocx gui` rouvre le tableau de bord à tout moment. +### Application macOS dans la barre des menus + +Un compagnon natif pour l’état du proxy, l’utilisation et les quotas des fournisseurs sans ouvrir +le tableau de bord. Le code source se trouve dans [`app/`](../app) (Swift + AppKit, sans dépendance +tierce). Téléchargez-le depuis la +[page des releases](https://github.com/lidge-jun/opencodex/releases) ou compilez-le localement avec +`bun run build:macos`. + +Le premier lancement nécessite un clic droit → Ouvrir, car l’application est signée ad hoc et non +notarisée. Consultez le [guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +pour l’explication complète. + +L’application inclut également un widget macOS 14+ affichant l’état du proxy, l’utilisation du jour et les quotas. Il peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs comptes ChatGPT / Codex et actualisez leurs quotas 5 h / hebdomadaires / 30 j dans le tableau de bord. Avec le routage par quota, les nouvelles sessions peuvent utiliser le compte opérationnel le moins sollicité ; diff --git a/readme/README.ja.md b/readme/README.ja.md index 415d5c9674d..ee8a7e6adc5 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -99,6 +99,18 @@ Codex 認証用の **ChatGPT アカウントプール**も管理できます。C は使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、アカウント に選択順を指定してください。 +### macOS メニューバーアプリ + +ダッシュボードを開かずにプロキシの状態、使用量、プロバイダーのクォータを確認できるネイティブ +コンパニオンです。ソースは [`app/`](../app)(Swift + AppKit、サードパーティ依存なし)にあります。 +[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 +`bun run build:macos` でローカルビルドできます。 + +アプリは未公証のアドホック署名のため、初回起動時は右クリックして「開く」を選択してください。 +詳しくは [macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)をご覧ください。 + +macOS 14 以降では、プロキシの状態、今日の使用量、クォータを表示するウィジェットも利用できます。 + ### スポンサー アップストリームのプロトコルが変わるたびに opencodex を追随させているのはスポンサーの支援です。 diff --git a/readme/README.ko.md b/readme/README.ko.md index f4c44f121c6..f93936bc5a5 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -96,6 +96,18 @@ round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레 계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. Codex Desktop 로그인처럼 다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면, 계정에 선택 순서를 지정하세요. +### macOS 메뉴 막대 앱 + +대시보드를 열지 않고 프록시 상태, 사용량, 제공자 쿼터를 확인하는 네이티브 동반 앱입니다. +소스는 [`app/`](../app)에 있으며 Swift + AppKit으로 작성되었고 서드파티 의존성이 없습니다. +[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 +`bun run build:macos`로 직접 빌드할 수 있습니다. + +앱은 공증되지 않은 애드혹 서명이므로 처음 실행할 때 마우스 오른쪽 버튼을 클릭하고 열기를 선택하세요. +자세한 내용은 [macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)를 참조하세요. + +macOS 14 이상에서는 프록시 상태, 오늘의 사용량과 쿼터를 보여 주는 위젯도 포함됩니다. + ### 스폰서 업스트림 프로토콜이 바뀔 때마다 opencodex가 따라갈 수 있는 건 스폰서 덕분입니다. 관심이 있으면 diff --git a/readme/README.ru.md b/readme/README.ru.md index e654f571a93..d2df43cd07f 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -103,6 +103,18 @@ ocx start # прокси + панель управлен них — обычно вход Codex Desktop — должен использоваться только после того, как остальные исчерпаны. +### Приложение macOS в строке меню + +Нативный компаньон для состояния прокси, использования и квот провайдеров без открытия панели. +Исходный код находится в [`app/`](../app) (Swift + AppKit, без сторонних зависимостей). +Скачайте его со [страницы релизов](https://github.com/lidge-jun/opencodex/releases) или +соберите локально командой `bun run build:macos`. + +При первом запуске нажмите правой кнопкой мыши и выберите «Открыть»: приложение подписано ad hoc, +но не нотариализовано. Подробности — в [руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/). + +Приложение также включает виджет для macOS 14+, показывающий состояние прокси, расход за сегодня и квоты. + ### Спонсоры Спонсоры позволяют поддерживать opencodex при каждом изменении вышестоящих протоколов. Интересно? diff --git a/readme/README.tr.md b/readme/README.tr.md index 998338d8760..511a42b0a1c 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -99,6 +99,18 @@ değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığ 429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde kullanılmasını istediğiniz bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim sırası verin. +### macOS menü çubuğu uygulaması + +Panoyu açmadan proxy durumunu, kullanımı ve sağlayıcı kotalarını gösteren yerel yardımcı uygulama. +Kaynak kodu [`app/`](../app) konumundadır (Swift + AppKit, üçüncü taraf bağımlılığı yoktur). +[Sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin veya +`bun run build:macos` ile yerel olarak derleyin. + +Uygulama noter tasdikli olmadığından ve ad hoc imzalandığından ilk açılışta sağ tıklayıp Aç'ı seçin. +Ayrıntılar için [macOS menü çubuğu uygulaması kılavuzuna](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) bakın. + +Uygulama ayrıca proxy durumunu, bugünkü kullanımı ve kotaları gösteren macOS 14+ widget'ını içerir. + ### Sponsorlar Her yukarı akış protokol değişiminde opencodex'in bakımını sürdürebilmesi sponsorlar sayesinde. diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 79392708a13..cc2e57271f3 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -97,6 +97,17 @@ ocx start # 代理 + 仪表板:localhost:10100 401/403 与 429 恢复,仍可能重新绑定。给账户设定选择顺序,以便其中某个账户 —— 通常是你的 Codex Desktop 登录 —— 只在其他账户耗尽后才被选中。 +### macOS 菜单栏应用 + +无需打开仪表板即可查看代理状态、用量和提供商配额的原生伴侣应用。源代码位于 +[`app/`](../app)(Swift + AppKit,无第三方依赖)。请从[发布页面](https://github.com/lidge-jun/opencodex/releases) +下载,或使用 `bun run build:macos` 在本地构建。 + +应用采用未公证的临时签名,首次启动时请右键点击并选择“打开”。详情请参阅 +[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 + +应用还包含适用于 macOS 14 及更高版本的小组件,可显示代理状态、今日用量和配额。 + ### 赞助商 赞助商支撑 opencodex 跟上每一次上游协议变更。有兴趣? diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 6a25aae4d15..adb8bf52012 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -95,6 +95,17 @@ ocx start # 代理 + 儀表板位於 localhost:10100 行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 +### macOS 選單列應用程式 + +無需開啟儀表板即可查看代理狀態、用量與供應商配額的原生伴侶應用程式。原始碼位於 +[`app/`](../app)(Swift + AppKit,沒有第三方相依套件)。請從[發行頁面](https://github.com/lidge-jun/opencodex/releases) +下載,或使用 `bun run build:macos` 在本機建置。 + +應用程式未經公證且使用 ad hoc 簽章,首次啟動時請按右鍵並選擇「開啟」。詳情請參閱 +[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 + +應用程式也包含 macOS 14 以上的小工具,可顯示代理狀態、今日用量與配額。 + ### 贊助 贊助讓 opencodex 能跟上每一次上游協議變更。有興趣? diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index d64870cad2c..569d3612db8 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" } } } diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 00000000000..8dcac03ee18 --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Assembles OpenCodex.app by hand. +# +# No Xcode project, so there is nothing to keep in sync with the package manifest. The +# bundle is staged in a temp directory and moved into place at the end, so an interrupted +# build never leaves a half-written .app that launches and misbehaves. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +package_dir="$repo_root/app" +output_root="${OUTPUT_DIR:-$repo_root/dist/macos}" +configuration="${CONFIGURATION:-release}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "build:macos requires macOS." >&2 + exit 1 +fi + +# Validate BEFORE creating anything, so the script cannot leave a directory behind at a +# path it then refuses to build into. +# +# `cd … && pwd` keeps LOGICAL paths on macOS, so a symlink inside the repository that +# points elsewhere would satisfy the prefix check below and then be deleted for real. +# Resolve physically: walk up to the nearest existing ancestor, resolve that, and +# re-append the parts that do not exist yet. +resolve_physical() { + local target="$1" part resolved + # Absolute-ise relative input against the caller's directory. + [[ "$target" = /* ]] || target="$PWD/$target" + + # ORDER MATTERS, and getting it wrong has been a bypass twice. + # + # 1. Normalise lexically FIRST. Resolving physically first and normalising afterwards + # lets `..` reveal a symlink that is then never physically resolved — so + # /.missing/../some-symlink passed containment while pointing elsewhere. + # 2. THEN walk up to the nearest existing ancestor of the normalised path and resolve + # that with `pwd -P`, which follows any symlinks that survived normalisation. + # + # Iteration is over a quoted array, never `for part in $tail`: word splitting there + # let a literal glob such as `rel*` expand against the filesystem. + local -a parts=() stack=() + local IFS=/ + read -r -a parts <<< "$target" + unset IFS + + for part in "${parts[@]}"; do + case "$part" in + "" | ".") continue ;; + "..") + # `unset 'stack[-1]'` is a bad subscript in bash 3.2 (what macOS ships), so it + # silently failed and `..` was never applied. Compute the index instead. + if [[ ${#stack[@]} -gt 0 ]]; then + unset "stack[$(( ${#stack[@]} - 1 ))]" + stack=("${stack[@]}") + fi + ;; + *) stack+=("$part") ;; + esac + done + + # Now resolve physically, component by component, so a symlink ANYWHERE along the + # surviving path is followed — including one that only became reachable because a + # `..` removed a non-existent parent above it. + # + # Resolving only the nearest existing ancestor is not enough: for + # /.missing/../outward-link the ancestor is , and the trailing + # `outward-link` symlink was re-appended unresolved and never followed. + resolved="/" + for part in "${stack[@]}"; do + local candidate="${resolved%/}/$part" + if [[ -L "$candidate" && ! -d "$candidate" ]]; then + # A symlink that is not a directory: dangling, or pointing at a file. Following it + # lexically was the third bypass here — a link to `../../outside` produced + # `/../../outside`, which satisfied the `/*` prefix check and then + # escaped during `mkdir -p`. There is no legitimate reason for OUTPUT_DIR to pass + # through such a link, so refuse instead of trying to be clever. + echo "Refusing to build through '$candidate': it is a symlink that does not" >&2 + echo "resolve to an existing directory." >&2 + exit 1 + fi + if [[ -d "$candidate" ]]; then + # `cd … && pwd -P` follows the symlink and any chain behind it. + resolved="$(cd "$candidate" && pwd -P)" + else + resolved="${resolved%/}/$part" + fi + done + printf '%s' "$resolved" +} + +output_root="$(resolve_physical "$output_root")" +app_bundle="$output_root/OpenCodex.app" + +# The build deletes whatever sits at $app_bundle, so the destination must be somewhere +# this project owns. Comparing $app_bundle against $output_root proves nothing — both +# come from the same variable, so pointing OUTPUT_DIR at /Applications would have passed +# and then recursively removed a real app. +allowed_root="$(cd "$repo_root" && pwd -P)" +if [[ -n "${TMPDIR:-}" ]]; then + allowed_tmp="$(cd "${TMPDIR%/}" 2>/dev/null && pwd -P || echo "")" +else + allowed_tmp="" +fi +case "$output_root" in + "$allowed_root"/*) ;; + /private/tmp/*|/tmp/*) ;; + *) + if [[ -z "$allowed_tmp" || "$output_root" != "$allowed_tmp"/* ]]; then + echo "Refusing to build into '$output_root': it is outside the repository and the" >&2 + echo "temp directory. Set OUTPUT_DIR to a path under $repo_root." >&2 + exit 1 + fi + ;; +esac + +mkdir -p "$output_root" + +swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexMenuBar) +widget_swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexWidget) + +if [[ "${UNIVERSAL:-0}" == "1" ]]; then + developer_dir="$(xcode-select -p 2>/dev/null || true)" + if [[ "$developer_dir" == *"CommandLineTools"* ]]; then + echo "UNIVERSAL=1 requires the full Xcode toolchain; Command Line Tools ships only" >&2 + echo "current-architecture Swift compatibility libraries, so the x86_64 slice cannot" >&2 + echo "link. Install Xcode, then:" >&2 + echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 + exit 1 + fi + swift_args+=(--arch arm64 --arch x86_64) + widget_swift_args+=(--arch arm64 --arch x86_64) +fi + +echo "==> Building ($configuration)…" +swift build "${swift_args[@]}" +swift build "${widget_swift_args[@]}" +bin_dir="$(swift build "${swift_args[@]}" --show-bin-path)" +executable="$bin_dir/OpenCodexMenuBar" +widget_bin_dir="$(swift build "${widget_swift_args[@]}" --show-bin-path)" +widget_executable="$widget_bin_dir/OpenCodexWidget" + +if [[ ! -x "$executable" ]]; then + echo "Build did not produce an executable at $executable" >&2 + exit 1 +fi +if [[ ! -x "$widget_executable" ]]; then + echo "Build did not produce an executable at $widget_executable" >&2 + exit 1 +fi + +staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" +staged_app="$staging_root/OpenCodex.app" +iconset="$staging_root/OpenCodex.iconset" +cleanup() { rm -rf "$staging_root"; } +trap cleanup EXIT + +mkdir -p "$staged_app/Contents/MacOS" "$staged_app/Contents/Resources" +cp "$executable" "$staged_app/Contents/MacOS/OpenCodexMenuBar" +cp "$package_dir/Info.plist" "$staged_app/Contents/Info.plist" +appex="$staged_app/Contents/PlugIns/OpenCodexWidget.appex" +mkdir -p "$appex/Contents/MacOS" +cp "$widget_executable" "$appex/Contents/MacOS/OpenCodexWidget" +cp "$package_dir/Widget-Info.plist" "$appex/Contents/Info.plist" + +# The app version comes from package.json, so it can never claim a version the release +# did not ship. +version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Could not read a valid version from package.json: '$version'" >&2 + exit 1 +fi + +# Apple constrains BOTH version fields, and differently from the npm version string: +# +# CFBundleShortVersionString - three period-separated integers. A prerelease suffix +# like "-preview.1" is not valid here. +# CFBundleVersion - ONE TO THREE period-separated integers. A fourth +# component is ignored, so appending a build number to a +# full semver produces no additional identity at all. +# +# So the short version is the numeric core, and when CI supplies a run number it becomes +# the CFBundleVersion outright — a monotonically increasing single integer is both valid +# and genuinely distinguishing, which "2.7.36." would not have been. +version_core="${version%%-*}" +if [[ ! "$version_core" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version core must be three integers for CFBundleShortVersionString: '$version_core'" >&2 + exit 1 +fi + +if [[ -n "${MACOS_BUILD_NUMBER:-}" ]]; then + if [[ ! "$MACOS_BUILD_NUMBER" =~ ^[0-9]+$ ]]; then + echo "MACOS_BUILD_NUMBER must be a positive integer, got '$MACOS_BUILD_NUMBER'" >&2 + exit 1 + fi + build_version="$MACOS_BUILD_NUMBER" +else + build_version="$version_core" +fi +if [[ ! "$build_version" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + echo "CFBundleVersion must be one to three integers, got '$build_version'" >&2 + exit 1 +fi + +plutil -replace CFBundleShortVersionString -string "$version_core" "$staged_app/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$build_version" "$staged_app/Contents/Info.plist" +plutil -replace CFBundleShortVersionString -string "$version_core" "$appex/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$build_version" "$appex/Contents/Info.plist" + +# Icon: reuse the dashboard favicon rather than adding another binary asset to the repo. +icon_source="$repo_root/gui/public/favicon.png" +if [[ ! -f "$icon_source" ]]; then + echo "Missing icon source: $icon_source" >&2 + exit 1 +fi +mkdir -p "$iconset" +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$icon_source" \ + --out "$iconset/icon_${size}x${size}.png" >/dev/null + sips -z "$((size * 2))" "$((size * 2))" "$icon_source" \ + --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" + +# Signing. +# +# MACOS_SIGN_IDENTITY selects a Developer ID Application certificate already present in +# the caller's keychain and enables the hardened runtime, which is what notarization +# requires. It is a LOCAL hook: CI does not set it, because an identity name alone +# cannot sign on a hosted runner — nothing imports the certificate and private key, so +# codesign fails with "no identity found". Wiring CI signing properly means a protected +# P12 import, a temporary keychain, notarytool credentials, and stapling. +# +# Without it the bundle is ad-hoc signed: structurally valid, but `spctl --assess` +# rejects it and a downloaded copy shows "cannot be opened because the developer cannot +# be verified". The project has no Developer ID certificate today, so ad-hoc is what +# ships and the docs must carry the right-click-Open path rather than pretend +# otherwise. +# +# The widget reads the host snapshot through its own bundle container fallback path. +# App Groups require a team-ID-prefixed group and a Developer ID / team-signed extension; +# ad-hoc signatures cannot satisfy that requirement, so the widget uses its own container. +if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + codesign --force --options runtime --timestamp \ + --entitlements "$package_dir/Widget.entitlements" \ + --sign "$MACOS_SIGN_IDENTITY" "$appex" + codesign --force --deep --options runtime --timestamp \ + --sign "$MACOS_SIGN_IDENTITY" "$staged_app" + echo "==> Signed with $MACOS_SIGN_IDENTITY (hardened runtime)" +else + codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ + --timestamp=none "$appex" + codesign --force --sign - --timestamp=none "$staged_app" + echo "==> Ad-hoc signed (no MACOS_SIGN_IDENTITY): Gatekeeper will require the" >&2 + echo " right-click-Open path on first launch." >&2 +fi + +if [[ -L "$app_bundle" ]]; then + echo "Refusing to replace '$app_bundle': it is a symlink." >&2 + exit 1 +fi +rm -rf "$app_bundle" +mv "$staged_app" "$app_bundle" + +echo "==> Built $app_bundle (release $version, short $version_core, build $build_version)" +lipo -archs "$app_bundle/Contents/MacOS/OpenCodexMenuBar" +lipo -archs "$app_bundle/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" diff --git a/scripts/package-macos-release.sh b/scripts/package-macos-release.sh new file mode 100755 index 00000000000..f9b0b1ebd89 --- /dev/null +++ b/scripts/package-macos-release.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wraps OpenCodex.app for distribution. +# +# Every step is an assertion rather than a hope: a release asset that is produced but +# empty, unsigned, or missing its executable is worse than no asset at all, because the +# failure surfaces on the user's machine instead of in CI. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +output_dir="${RELEASE_OUTPUT_DIR:-$repo_root/dist/release}" +universal="${UNIVERSAL:-1}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "package:macos requires macOS." >&2 + exit 1 +fi + +package_version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" +if [[ ! "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid package version for the macOS release asset: '$package_version'" >&2 + exit 1 +fi + +# A release dispatched for one version must never package a different one. +if [[ -n "${RELEASE_VERSION:-}" && "$RELEASE_VERSION" != "$package_version" ]]; then + echo "package.json ($package_version) does not match the requested release (${RELEASE_VERSION})" >&2 + exit 1 +fi + +if [[ "$universal" != "0" && "$universal" != "1" ]]; then + echo "UNIVERSAL must be 0 or 1." >&2 + exit 1 +fi + +mkdir -p "$output_dir" +output_dir="$(cd "$output_dir" && pwd)" + +build_root="$(mktemp -d "${TMPDIR:-/tmp}/OpenCodex-release.XXXXXX")" +cleanup() { rm -rf "$build_root"; } +trap cleanup EXIT + +OUTPUT_DIR="$build_root" UNIVERSAL="$universal" CONFIGURATION=release \ + bash "$script_dir/build-macos-app.sh" >&2 + +app_bundle="$build_root/OpenCodex.app" +executable="$app_bundle/Contents/MacOS/OpenCodexMenuBar" + +codesign --verify --deep --strict --verbose=2 "$app_bundle" + +# Report the Gatekeeper verdict rather than discovering it on a user's machine. An +# ad-hoc build is expected to be rejected; that is documented, not a packaging failure. +# A build that claimed a real identity and STILL fails assessment is a failure. +if spctl --assess --type execute "$app_bundle" >/dev/null 2>&1; then + echo "==> Gatekeeper: accepted" >&2 +else + if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + echo "Signed with $MACOS_SIGN_IDENTITY but Gatekeeper still rejects the bundle." >&2 + echo "It likely needs notarization (notarytool) and a stapled ticket." >&2 + exit 1 + fi + echo "==> Gatekeeper: rejected (expected for an ad-hoc signature)." >&2 + echo " Users must right-click > Open on first launch; this is documented." >&2 +fi + +architectures="$(lipo -archs "$executable")" +if [[ "$universal" == "1" ]]; then + for required_arch in arm64 x86_64; do + if [[ " $architectures " != *" $required_arch "* ]]; then + echo "Universal build is missing $required_arch (got: $architectures)" >&2 + exit 1 + fi + done + architecture_label="universal" +else + architecture_label="${architectures// /-}" +fi + +archive_name="OpenCodex-${package_version}-macos-${architecture_label}.zip" +checksum_name="${archive_name}.sha256" +archive_path="$output_dir/$archive_name" +checksum_path="$output_dir/$checksum_name" +rm -f "$archive_path" "$checksum_path" + +# ditto rather than zip: it preserves extended attributes and symlinks, so the unpacked +# bundle stays launchable. Plain zip corrupts the code signature. +ditto -c -k --sequesterRsrc --keepParent "$app_bundle" "$archive_path" + +# An archive that exists but does not contain the executable is the failure mode this +# assertion exists to catch. +archive_entries="$(unzip -Z1 "$archive_path")" +if ! grep -Fqx 'OpenCodex.app/Contents/MacOS/OpenCodexMenuBar' <<< "$archive_entries"; then + echo "Packaged archive does not contain the OpenCodex executable." >&2 + echo "Archive entries were:" >&2 + printf '%s\n' "$archive_entries" | head -20 >&2 + exit 1 +fi + +( + cd "$output_dir" + shasum -a 256 "$archive_name" > "$checksum_name" +) + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "archive_name=$archive_name" + echo "checksum_name=$checksum_name" + } >> "$GITHUB_OUTPUT" +fi + +echo "$archive_path" +echo "$checksum_path" diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f9d1fe63007..37969710498 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,6 +167,7 @@ } }, "explicit": { + "macos-build-script.test.ts": "gui", "server-combo-held-response.test.ts": "server", "key-attribution.test.ts": "usage", "provider-send-path-import.test.ts": "server", @@ -189,6 +190,7 @@ "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", @@ -1581,6 +1583,8 @@ "management-google-tool-schema-policy.test.ts": "server", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "usage-timeline.test.ts": "usage", + "companion-settings.test.ts": "server", "chat-tool-choice-allowed-tools.test.ts": "responses", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "google-strict-tool-validated-mode.test.ts": "adapters/google", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 78d2047d1aa..4918176da31 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -537,6 +537,25 @@ JSON mode: `payload`. - `store` verifies every keychain write by read-back before config.json is rewritten with keychain: references; an unavailable keychain refuses with 503 and leaves the file untouched. - Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there. +### `ocx companion` + +Inspect and configure menu-bar and widget companion usage settings. + +| Method | Route | +|---|---| +| GET | `/api/companion/settings` | +| GET | `/api/usage/timeline` | +| PUT | `/api/companion/settings` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit companion settings as JSON. | + +JSON mode: `payload`. + +- `show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults. +- Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly. + ### `ocx account main reauth` Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring. @@ -896,6 +915,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 48 -- of those, state-changing: 24 +- declared capabilities: 49 +- of those, state-changing: 25 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index e67390041b9..f651183d053 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -314,6 +314,22 @@ export const CAPABILITIES: readonly Capability[] = [ "Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.", ], }, + { + command: ["companion"], + summary: "Inspect and configure menu-bar and widget companion usage settings.", + routes: [ + { method: "GET", path: "/api/companion/settings" }, + { method: "GET", path: "/api/usage/timeline" }, + { method: "PUT", path: "/api/companion/settings" }, + ], + flags: [{ name: "--json", value: "boolean", summary: "Emit companion settings as JSON." }], + mutates: true, + json: "payload", + details: [ + "`show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults.", + "Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly.", + ], + }, { command: ["account", "history"], summary: "Cached quota observations for one stored Codex pool account.", diff --git a/src/cli/companion.ts b/src/cli/companion.ts new file mode 100644 index 00000000000..2b24258fcc3 --- /dev/null +++ b/src/cli/companion.ts @@ -0,0 +1,56 @@ +import { CliUsageError, printData, rejectArgs, runCliAction, runtimeRequest, takeFlag, type RuntimeApiDeps } from "./runtime-api"; + +const USAGE = `Usage: + ocx companion [show] [--json] + ocx companion set = [...] [--json] + ocx companion reset [--json]`; + +function parseValue(raw: string): unknown { + if (raw === "null") return null; + try { return JSON.parse(raw); } catch { return raw; } +} + +async function show(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + printData(await runtimeRequest("/api/companion/settings", {}, deps), wantsJson); +} + +async function set(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + if (args.length === 0) throw new CliUsageError("companion set requires key=value assignments", USAGE); + const patch: Record = {}; + for (const assignment of args) { + const separator = assignment.indexOf("="); + if (separator <= 0) throw new CliUsageError(`invalid companion setting "${assignment}"; use key=value`, USAGE); + patch[assignment.slice(0, separator)] = parseValue(assignment.slice(separator + 1)); + } + printData(await runtimeRequest("/api/companion/settings", { + method: "PUT", + body: JSON.stringify({ settings: patch }), + }, deps), wantsJson, ["Companion settings saved."]); +} + +async function reset(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + printData(await runtimeRequest("/api/companion/settings", { + method: "PUT", + body: JSON.stringify({ reset: true }), + }, deps), wantsJson, ["Companion settings reset."]); +} + +export async function handleCompanionCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + const [sub = "show", ...rest] = argv; + if (sub === "show") await show(rest, deps); + else if (sub === "set") await set(rest, deps); + else if (sub === "reset") await reset(rest, deps); + else throw new CliUsageError(`unknown companion command ${sub}`, USAGE); + }); +} + +export const COMPANION_USAGE = USAGE; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b9b09420c87..beb3170d992 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -786,6 +786,10 @@ const commandRunners: Record = { const { handleComboCommand } = await import("./combo"); return await handleComboCommand(deps.args.slice(1)); }, + companion: async deps => { + const { handleCompanionCommand } = await import("./companion"); + return await handleCompanionCommand(deps.args.slice(1)); + }, route: async deps => { if (deps.args[1] !== "combo" && deps.args[1] !== "policy") { console.error("Usage: ocx route "); diff --git a/src/cli/help.ts b/src/cli/help.ts index 0be199f1a5a..da5450880b4 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -89,6 +89,7 @@ Usage: ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection ocx config Validated configuration show/get/set/import/export + ocx companion Menu-bar and widget companion usage settings ocx lab Read-only Compatibility Lab projection inspection ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f9b9d95d969..85c2d660310 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -298,6 +298,16 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx model ", summary: "Alias of ocx models.", }, + { + name: "companion", + usage: "ocx companion ...", + summary: "Inspect and configure menu-bar and widget companion usage settings.", + details: [ + "ocx companion and ocx companion show read settings; use --json for machine-readable output.", + "ocx companion set accepts one or more key=value assignments; values are parsed as JSON when possible.", + "ocx companion reset restores the default settings.", + ], + }, { name: "combo", usage: "ocx combo ...", diff --git a/src/companion/settings.ts b/src/companion/settings.ts new file mode 100644 index 00000000000..c838285b6fe --- /dev/null +++ b/src/companion/settings.ts @@ -0,0 +1,131 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { + TIMELINE_HOURS, + type TimelineAggregation, + type TimelineGrouping, + type TimelineMetric, +} from "../usage/timeline"; + +export interface CompanionSettings { + menuBarMetric: "requests" | "tokens" | "cost" | "quota" | "none"; + menuBarTemplate: string | null; + showToday: boolean; + showChart: boolean; + showModels: boolean; + showCost: boolean; + showAccounts: boolean; + chartHours: typeof TIMELINE_HOURS[number]; + bucketMinutes: number; + chartStyle: "line" | "stackedBar"; + tokenMetric: TimelineMetric; + aggregation: TimelineAggregation; + chartGrouping: TimelineGrouping; + models: string[] | null; + hiddenProviders: string[]; +} + +export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = { + menuBarMetric: "tokens", + menuBarTemplate: null, + showToday: true, + showChart: true, + showModels: true, + showCost: true, + showAccounts: true, + chartHours: 24, + bucketMinutes: 60, + chartStyle: "line", + tokenMetric: "total", + aggregation: "sum", + chartGrouping: "model", + models: null, + hiddenProviders: [], +}; + +const TEMPLATE_FIELDS = new Set(["requests", "totalTokens", "inputTokens", "outputTokens", "costUsd", "quotaPercent"]); +const MENU_BAR_METRICS = new Set(["requests", "tokens", "cost", "quota", "none"]); +const CHART_STYLES = new Set(["line", "stackedBar"]); +const TIMELINE_METRICS = new Set(["total", "input", "output", "cached"]); +const AGGREGATIONS = new Set(["sum", "average", "max"]); +const GROUPINGS = new Set(["model", "modelAccount"]); +const SETTINGS_KEYS = Object.keys(DEFAULT_COMPANION_SETTINGS) as (keyof CompanionSettings)[]; + +export function companionSettingsPath(): string { + return join(getConfigDir(), "companion.json"); +} + +function invalid(message: string): { error: string } { + return { error: message }; +} + +function validModels(value: unknown, key: string): value is string[] | null { + return value === null + || (Array.isArray(value) + && value.length <= 100 + && value.every(model => typeof model === "string" && /^[^/\s]+\/[^/\s]+$/.test(model))); +} + +function validateValue(key: keyof CompanionSettings, value: unknown): string | null { + if (key === "menuBarMetric") return typeof value === "string" && MENU_BAR_METRICS.has(value) ? null : "menuBarMetric is invalid"; + if (key === "menuBarTemplate") { + if (value === null) return null; + if (typeof value !== "string" || value.length > 200) return "menuBarTemplate must be null or at most 200 characters"; + for (const match of value.matchAll(/\{([^{}]+)\}/g)) { + if (!TEMPLATE_FIELDS.has(match[1]!)) return `menuBarTemplate contains unknown placeholder: ${match[1]}`; + } + return null; + } + if (["showToday", "showChart", "showModels", "showCost", "showAccounts"].includes(key)) { + return typeof value === "boolean" ? null : `${key} must be a boolean`; + } + if (key === "chartHours") return TIMELINE_HOURS.includes(value as typeof TIMELINE_HOURS[number]) ? null : "chartHours is invalid"; + if (key === "bucketMinutes") return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 1440 ? null : "bucketMinutes must be an integer from 1 through 1440"; + if (key === "chartStyle") return typeof value === "string" && CHART_STYLES.has(value) ? null : "chartStyle is invalid"; + if (key === "tokenMetric") return typeof value === "string" && TIMELINE_METRICS.has(value) ? null : "tokenMetric is invalid"; + if (key === "aggregation") return typeof value === "string" && AGGREGATIONS.has(value) ? null : "aggregation is invalid"; + if (key === "chartGrouping") return typeof value === "string" && GROUPINGS.has(value) ? null : "chartGrouping is invalid"; + if (key === "models") return validModels(value, key) ? null : "models must be null or at most 100 provider/model identifiers"; + if (key === "hiddenProviders") return Array.isArray(value) && value.length <= 100 && value.every(item => typeof item === "string" && item.length > 0 && !/\s/.test(item)) + ? null : "hiddenProviders must contain at most 100 provider names"; + return `${key} is unsupported`; +} + +export function applyCompanionSettingsPatch( + current: CompanionSettings, + patch: unknown, +): CompanionSettings | { error: string } { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) return invalid("settings must be an object"); + const values = patch as Record; + for (const key of Object.keys(values)) { + if (!SETTINGS_KEYS.includes(key as keyof CompanionSettings)) return invalid(`unknown settings key: ${key}`); + const error = validateValue(key as keyof CompanionSettings, values[key]); + if (error) return invalid(error); + } + return { ...current, ...values } as CompanionSettings; +} + +export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null; corrupt?: true } { + const path = companionSettingsPath(); + if (!existsSync(path)) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null }; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + const settings = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, parsed); + if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true }; + return { settings, updatedAt: statSync(path).mtimeMs }; + } catch { + return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true }; + } +} + +export function saveCompanionSettings(settings: CompanionSettings): void { + const path = companionSettingsPath(); + const dir = getConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const temp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); + chmodSync(temp, 0o600); + renameSync(temp, path); + chmodSync(path, 0o600); +} diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 7c8766367c2..dbd5d7345d6 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -71,6 +71,8 @@ import { handleOauthAccountRoutes } from "./management/oauth-account-routes"; import { handleComboRoutes } from "./management/combo-routes"; import { handleSystemRoutes } from "./management/system-routes"; import { handleSidebarRoutes } from "./management/sidebar-routes"; +import { handleUsageTimelineRoutes } from "./management/usage-timeline-routes"; +import { handleCompanionRoutes } from "./management/companion-routes"; import { handleCodexPromptRoutes } from "./management/codex-prompt-routes"; import { handleIntegrationRoutes } from "./management/integration-routes"; import { handleNativeIntegrationRoutes } from "./management/native-integration-routes"; @@ -266,7 +268,7 @@ export async function handleManagementAPI( } catch { /* best-effort */ } } const ctx: ManagementContext = { req, url, config, deps, version: VERSION, principal, sessionControl, convergeCodexCatalog, syncClaudeAgentDefsBestEffort }; - let routed: Response | null; + let routed: Response | null | undefined; try { routed = handleSessionRoutes(ctx) ?? (await handleRemoteWorkspaceRoutesOnDemand(ctx)) @@ -291,6 +293,8 @@ export async function handleManagementAPI( ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) ?? (await handleLabRoutesOnDemand(ctx)) + ?? (await handleUsageTimelineRoutes(ctx)) + ?? (await handleCompanionRoutes(ctx)) ?? (await handleSidebarRoutes(ctx)); } catch (error) { const tooLarge = managementBodyTooLargeResponse(error, req, config); diff --git a/src/server/management/companion-routes.ts b/src/server/management/companion-routes.ts new file mode 100644 index 00000000000..df0fd8ab6de --- /dev/null +++ b/src/server/management/companion-routes.ts @@ -0,0 +1,53 @@ +import { + applyCompanionSettingsPatch, + DEFAULT_COMPANION_SETTINGS, + loadCompanionSettings, + saveCompanionSettings, +} from "../../companion/settings"; +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; + +let companionLastSeenAt: number | null = null; + +export function resetCompanionPresenceForTests(): void { + companionLastSeenAt = null; +} + +function response(): Response { + const loaded = loadCompanionSettings(); + return jsonResponse({ + settings: loaded.settings, + updatedAt: loaded.updatedAt, + defaults: DEFAULT_COMPANION_SETTINGS, + companion: { lastSeenAt: companionLastSeenAt }, + ...(loaded.corrupt ? { corrupt: true } : {}), + }); +} + +export async function handleCompanionRoutes(ctx: ManagementContext): Promise { + if (ctx.url.pathname === "/api/companion/settings" && ctx.req.method === "GET") { + if (ctx.req.headers.get("user-agent")?.startsWith("OpenCodexMenuBar/")) companionLastSeenAt = Date.now(); + return response(); + } + if (ctx.url.pathname !== "/api/companion/settings" || ctx.req.method !== "PUT") return null; + let body: unknown; + try { + body = await readManagementJsonBody(ctx.req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400, ctx.req, ctx.config); + } + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid settings body" }, 400, ctx.req, ctx.config); + const input = body as { reset?: unknown; settings?: unknown }; + if (input.reset === true) { + saveCompanionSettings(DEFAULT_COMPANION_SETTINGS); + return response(); + } + if (!("settings" in input)) return jsonResponse({ error: "provide settings or reset:true" }, 400, ctx.req, ctx.config); + const current = loadCompanionSettings().settings; + const updated = applyCompanionSettingsPatch(current, input.settings); + if ("error" in updated) return jsonResponse(updated, 400, ctx.req, ctx.config); + saveCompanionSettings(updated); + return response(); +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 95dc71be749..fbaafcb5d36 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -240,6 +240,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/storage/trash", module: "server/management/logs-usage-routes", mutates: false }, { method: "GET", path: "/api/storage/trash/restore/test-stream", module: "server/management/logs-usage-routes", mutates: false, exempt: { reason: "test-seam", why: "Opt-in streaming seam declared at src/storage/restore-job.ts:34." } }, { method: "GET", path: "/api/usage", module: "server/management/logs-usage-routes", mutates: false }, + // server/management/usage-timeline-routes + { method: "GET", path: "/api/usage/timeline", module: "server/management/usage-timeline-routes", mutates: false }, + // server/management/companion-routes + { method: "GET", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: false }, + { method: "PUT", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup", module: "server/management/logs-usage-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup-policy/run", module: "server/management/logs-usage-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup/preview", module: "server/management/logs-usage-routes", mutates: true }, diff --git a/src/server/management/usage-timeline-routes.ts b/src/server/management/usage-timeline-routes.ts new file mode 100644 index 00000000000..d4f04c37186 --- /dev/null +++ b/src/server/management/usage-timeline-routes.ts @@ -0,0 +1,44 @@ +import { readUsageSnapshotForManagement } from "../../usage/log"; +import { createTimelineAccumulator, parseTimelineQuery } from "../../usage/timeline"; +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; + +const TIMELINE_CACHE_TTL_MS = 15_000; +const cache = new Map["finish"]>> }>(); + +export async function handleUsageTimelineRoutes(ctx: ManagementContext): Promise { + const { req, url } = ctx; + if (url.pathname !== "/api/usage/timeline" || req.method !== "GET") return undefined; + const query = parseTimelineQuery(url.searchParams, Date.now()); + if ("error" in query) return jsonResponse(query, 400, req, ctx.config); + const bucketMs = query.bucketMinutes * 60_000; + const roundedNow = Math.floor(query.now / bucketMs) * bucketMs; + const normalized = { ...query, now: roundedNow }; + const key = JSON.stringify(normalized); + const current = Date.now(); + const cached = cache.get(key); + if (cached && cached.expiresAt > current) return jsonResponse(await cached.promise, 200, req, ctx.config); + let promise: Promise["finish"]>>; + promise = (async () => { + const accumulator = createTimelineAccumulator(normalized); + const snapshot = await readUsageSnapshotForManagement(ctx.config.managementUsageMaxReadBytes); + for (const entry of snapshot.entries) accumulator.add(entry); + return { + ...accumulator.finish(), + truncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, + }; + })().catch(error => { + const entry = cache.get(key); + if (entry?.promise === promise) cache.delete(key); + throw error; + }); + cache.set(key, { expiresAt: current + TIMELINE_CACHE_TTL_MS, promise }); + try { + return jsonResponse(await promise, 200, req, ctx.config); + } finally { + setTimeout(() => { + const entry = cache.get(key); + if (entry?.promise === promise && entry.expiresAt <= Date.now()) cache.delete(key); + }, TIMELINE_CACHE_TTL_MS + 1); + } +} diff --git a/src/usage/summary.ts b/src/usage/summary.ts index b1468fd1258..091dac17632 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -410,7 +410,7 @@ function isMeasuredStatus(status: UsageStatus): boolean { return status === "reported" || status === "estimated"; } -interface UsageAttribution { +export interface UsageAttribution { requestId: string; provider: string; model: string; @@ -454,7 +454,7 @@ function usageModelKey(providerKey: string, model: string): string { return `${providerKey}\0${model}`; } -function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { +export function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { if (!entry.attempts?.length) { return [{ requestId: entry.requestId, diff --git a/src/usage/timeline.ts b/src/usage/timeline.ts new file mode 100644 index 00000000000..af44587f512 --- /dev/null +++ b/src/usage/timeline.ts @@ -0,0 +1,219 @@ +import { cacheTokensFromUsage, usageAttributions } from "./summary"; +import type { PersistedUsageEntry } from "./log"; +import { usageDisplayTotalTokens } from "./totals"; + +export type TimelineMetric = "total" | "input" | "output" | "cached"; +export type TimelineAggregation = "sum" | "average" | "max"; +export type TimelineGrouping = "model" | "modelAccount"; +export const TIMELINE_HOURS = [6, 24, 72, 168] as const; + +export interface TimelineQuery { + hours: typeof TIMELINE_HOURS[number]; + bucketMinutes: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + models: string[] | null; + now: number; +} + +export interface TimelineSeries { + id: string; + provider: string; + model: string; + accountLogLabel?: string; + total: number; + points: number[]; +} + +export interface UsageTimeline { + start: number; + end: number; + bucketSeconds: number; + buckets: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + series: TimelineSeries[]; + availableModels: string[]; + missingMeasurements: number; + truncated: boolean; +} + +const METRICS: readonly TimelineMetric[] = ["total", "input", "output", "cached"]; +const AGGREGATIONS: readonly TimelineAggregation[] = ["sum", "average", "max"]; +const GROUPINGS: readonly TimelineGrouping[] = ["model", "modelAccount"]; + +function enumValue(value: string | null, values: readonly T[], fallback: T): T | { error: string } { + if (value === null || value === "") return fallback; + return values.includes(value as T) ? value as T : { error: `invalid value for parameter: ${value}` }; +} + +function parseModels(raw: string | null): string[] | null | { error: string } { + if (raw === null || raw.trim() === "") return null; + const models = raw.split(",").map(model => model.trim()); + if (models.length > 100) return { error: "models must contain at most 100 identifiers" }; + if (models.some(model => !/^[^/\s]+\/[^/\s]+$/.test(model))) { + return { error: "models must contain provider/model identifiers" }; + } + return [...new Set(models)]; +} + +export function parseTimelineQuery(params: URLSearchParams, now: number): TimelineQuery | { error: string } { + const rawHours = params.get("hours") ?? "24"; + const hoursNumber = Number(rawHours); + if (!TIMELINE_HOURS.includes(hoursNumber as typeof TIMELINE_HOURS[number])) { + return { error: "hours must be one of 6, 24, 72, 168" }; + } + const bucketMinutes = Number(params.get("bucketMinutes") ?? "60"); + if (!Number.isInteger(bucketMinutes) || bucketMinutes < 1 || bucketMinutes > 1440) { + return { error: "bucketMinutes must be an integer from 1 through 1440" }; + } + const buckets = Math.ceil(hoursNumber * 60 / bucketMinutes); + if (buckets > 2000) return { error: "timeline bucket count must not exceed 2000" }; + const metric = enumValue(params.get("metric"), METRICS, "total"); + if (typeof metric !== "string") return metric; + const aggregation = enumValue(params.get("aggregation"), AGGREGATIONS, "sum"); + if (typeof aggregation !== "string") return aggregation; + const grouping = enumValue(params.get("grouping"), GROUPINGS, "model"); + if (typeof grouping !== "string") return grouping; + const models = parseModels(params.get("models")); + if (typeof models === "object" && models !== null && "error" in models) return models; + if (!Number.isFinite(now)) return { error: "now must be finite" }; + return { + hours: hoursNumber as TimelineQuery["hours"], + bucketMinutes, + metric, + aggregation, + grouping, + models: models as string[] | null, + now, + }; +} + +interface SeriesState { + provider: string; + model: string; + accountLogLabel?: string; + points: number[]; + requests: Map>; +} + +function metricValue(metric: TimelineMetric, attribution: ReturnType[number]): number | undefined { + if (metric === "total") return usageDisplayTotalTokens(attribution.usage, attribution.totalTokens); + if (metric === "input") return attribution.usage?.inputTokens; + if (metric === "output") return attribution.usage?.outputTokens; + return cacheTokensFromUsage(attribution.usage).read; +} + +export function createTimelineAccumulator(query: TimelineQuery): { add(entry: PersistedUsageEntry): void; finish(): UsageTimeline } { + const bucketSeconds = query.bucketMinutes * 60; + const start = Math.floor((query.now - query.hours * 3_600_000) / 1000 / bucketSeconds) * bucketSeconds; + const buckets = Math.ceil(query.hours * 60 / query.bucketMinutes); + const end = start + buckets * bucketSeconds; + const startMs = start * 1000; + const endMs = end * 1000; + const series = new Map(); + const availableModels = new Set(); + let missingMeasurements = 0; + + function add(entry: PersistedUsageEntry): void { + if (entry.timestamp < startMs || entry.timestamp >= endMs) return; + const bucket = Math.floor((entry.timestamp - startMs) / (bucketSeconds * 1000)); + if (bucket < 0 || bucket >= buckets) return; + for (const attribution of usageAttributions(entry)) { + const modelId = `${attribution.provider}/${attribution.model}`; + availableModels.add(modelId); + if (query.models && !query.models.includes(modelId)) continue; + const id = query.grouping === "model" + ? modelId + : `${modelId} · ${attribution.accountLogLabel ?? "unknown"}`; + let state = series.get(id); + if (!state) { + state = { + provider: attribution.provider, + model: attribution.model, + ...(query.grouping === "modelAccount" ? { accountLogLabel: attribution.accountLogLabel ?? "unknown" } : {}), + points: Array(buckets).fill(0), + requests: new Map(), + }; + series.set(id, state); + } + const value = metricValue(query.metric, attribution); + if (value === undefined) { + missingMeasurements += 1; + continue; + } + if (query.aggregation === "sum") { + state.points[bucket] = (state.points[bucket] ?? 0) + value; + } else { + let requests = state.requests.get(bucket); + if (!requests) { + requests = new Map(); + state.requests.set(bucket, requests); + } + requests.set(attribution.requestId, (requests.get(attribution.requestId) ?? 0) + value); + } + } + } + + function finish(): UsageTimeline { + const rows = [...series].map(([id, state]): { row: TimelineSeries; state: SeriesState } => { + if (query.aggregation !== "sum") { + for (const [bucket, requests] of state.requests) { + const values = [...requests.values()]; + state.points[bucket] = query.aggregation === "max" + ? Math.max(...values) + : values.reduce((sum, value) => sum + value, 0) / values.length; + } + } + const total = state.points.reduce((sum, value) => sum + value, 0); + return { + row: { + id, + provider: state.provider, + model: state.model, + ...(state.accountLogLabel !== undefined ? { accountLogLabel: state.accountLogLabel } : {}), + total, + points: state.points, + }, + state, + }; + }).sort((left, right) => right.row.total - left.row.total || left.row.id.localeCompare(right.row.id)); + const kept = (rows.length > 24 ? rows.slice(0, 23) : rows).map(({ row }) => row); + if (rows.length > 24) { + const otherPoints = Array(buckets).fill(0); + const folded = rows.slice(23); + if (query.aggregation === "sum") { + for (const { row } of folded) { + for (let index = 0; index < buckets; index += 1) otherPoints[index] = (otherPoints[index] ?? 0) + (row.points[index] ?? 0); + } + } else { + for (let index = 0; index < buckets; index += 1) { + const values = folded.flatMap(({ state }) => [...(state.requests.get(index)?.values() ?? [])]); + if (values.length > 0) { + otherPoints[index] = query.aggregation === "max" + ? Math.max(...values) + : values.reduce((sum, value) => sum + value, 0) / values.length; + } + } + } + kept.push({ id: "other", provider: "", model: "other", total: otherPoints.reduce((sum, value) => sum + value, 0), points: otherPoints }); + } + return { + start, + end, + bucketSeconds, + buckets, + metric: query.metric, + aggregation: query.aggregation, + grouping: query.grouping, + series: kept, + availableModels: [...availableModels].sort(), + missingMeasurements, + truncated: false, + }; + } + + return { add, finish }; +} diff --git a/structure/INDEX.md b/structure/INDEX.md index cd101960def..40c82166e69 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -90,6 +90,7 @@ A source area can be described by more than one doc, because these docs are orga | Source path | Described by | | --- | --- | | `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `app/` | [`overview.md`](overview.md) | | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `gui/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`design-methodology.md`](design-methodology.md) | @@ -103,6 +104,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/clients/` | [`clients/integrations.md`](clients/integrations.md) | | `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | +| `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | | `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md) | | `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | @@ -132,6 +134,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/update/` | [`runtime.md`](runtime.md) | | `src/usage/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | +| `src/usage/timeline.ts` | [`gui-and-management-api.md`](gui-and-management-api.md) | | `src/vision/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | | `src/web-search/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 218df3838d5..47bd09d1cb2 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,9 @@ # GUI And Management API +The companion settings contract in `src/companion/` persists menu-bar and widget display +preferences, while `src/server/management/companion-routes.ts` exposes those settings and the +usage timeline assembled by `src/usage/timeline.ts` to local clients. + Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation is a local CLI surface, not a management API or GUI update permission. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). @@ -142,7 +146,7 @@ this document owns is which module holds which area and what invariant that area | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | -| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | +| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; its `spendLedger` block reports only ownership held/unheld, initialized/configured/degraded booleans and bounded persistence/corruption counters. Reading it never constructs, replays or prunes the ledger. Paths, scopes, accounts and request ids are absent, and the block never moves to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | diff --git a/structure/manifest.json b/structure/manifest.json index cbd3c7da0fc..20e69a13f39 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -49,7 +49,9 @@ "title": "Overview", "scope": "Product boundary, local state ownership, and the non-negotiable invariants index.", "documents": [ + "app/", "gui/", + "src/companion/", "scripts/", "src/config.ts", "src/lib/" @@ -326,10 +328,12 @@ "scope": "Dashboard serving, authentication boundaries, /api/* ownership, and usage accounting.", "documents": [ "gui/", + "src/companion/", "src/codex/", "src/lib/", "src/server/", "src/usage/", + "src/usage/timeline.ts", "src/vision/" ] }, diff --git a/structure/overview.md b/structure/overview.md index 7178b292b70..48e24dc1dc5 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -33,6 +33,12 @@ native Anthropic passthrough branch that forwards without translation. The Live/ different in kind — it resolves an OpenAI/ChatGPT relay and forwards to it directly, without the adapter bridge. +`app/` is a second, optional surface: a native macOS menu bar companion. It is a client +of the management API, not part of the proxy — it adds no endpoint and changes no +routing. Treat it the way you treat `gui/`: it may consume what `src/` already exposes, +and a change that requires a new endpoint is a change to the proxy first. +Its persisted display contract is owned by `src/companion/`. + The default install keeps native OpenAI/ChatGPT passthrough working through one option-aware `openai` provider. Pool is the default and selects across main plus added accounts; Direct uses only the current caller/main login. `openai-apikey` explicitly selects API-key transport, and the two diff --git a/tests/ci-workflows/ci-structure-gate.test.ts b/tests/ci-workflows/ci-structure-gate.test.ts index b6f21009092..0cda8de5330 100644 --- a/tests/ci-workflows/ci-structure-gate.test.ts +++ b/tests/ci-workflows/ci-structure-gate.test.ts @@ -67,8 +67,17 @@ test("the aggregate gate expects the job instead of ignoring it", () => { // job missing from `expected_for` reads as `undeclared`, not as skipped. const gate = workflow.jobs?.ci; expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("structure-gate"); + expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("macos-app"); const script = (gate?.steps ?? []).map(step => step.run ?? "").join("\n"); expect(script).toContain("structure-gate) echo \"$structure\" ;;"); - expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate\""); + expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate macos-app\""); + expect(script).toContain("|macos-app)"); expect(script).toContain("CHANGES_STRUCTURE"); }); + +test("app changes select the macOS app job", () => { + expect(filters.ci).toContain("app/**"); + const macosApp = workflow.jobs?.["macos-app"]; + expect(macosApp?.if).toContain("needs.changes.outputs.ci == 'true'"); + expect(Array.isArray(macosApp?.needs) ? macosApp?.needs : []).toContain("changes"); +}); diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 22b02edb141..658bd378346 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -495,6 +495,7 @@ describe("GitHub Actions hardening", () => { "Dockerfile", "LICENSE", "README.md", + "app/**", "assets/**", "bin/**", "bun.lock", diff --git a/tests/cli/cli-companion.test.ts b/tests/cli/cli-companion.test.ts new file mode 100644 index 00000000000..2f3d361bacb --- /dev/null +++ b/tests/cli/cli-companion.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { handleCompanionCommand } from "../../src/cli/companion"; + +describe("ocx companion", () => { + test("set parses JSON values and reset sends the matching management payload", async () => { + const requests: Array<{ path: string; method: string; body: unknown }> = []; + const deps = { + baseUrl: "http://proxy.test", + fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) : null; + requests.push({ path: new URL(String(input)).pathname, method: init?.method ?? "GET", body }); + return Response.json({ settings: {}, defaults: {}, updatedAt: null }); + }, + }; + expect(await handleCompanionCommand(["set", "showChart=false", "chartHours=6", "menuBarTemplate=null", "--json"], deps)).toBe(0); + expect(requests[0]).toEqual({ + path: "/api/companion/settings", + method: "PUT", + body: { settings: { showChart: false, chartHours: 6, menuBarTemplate: null } }, + }); + expect(await handleCompanionCommand(["reset"], deps)).toBe(0); + expect(requests[1]).toEqual({ + path: "/api/companion/settings", + method: "PUT", + body: { reset: true }, + }); + }); +}); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index c8b7257a8d0..27e9f657ff8 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -404,6 +404,7 @@ describe("headless GUI parity CLI", () => { ["/api/logs", "ocx observe"], ["/api/lab", "ocx lab"], ["/api/config", "ocx config"], + ["/api/companion", "ocx companion"], // The client machine plane. These are served by the connected client's own loopback // listener rather than the hub, and each one mirrors a connect-family command: // status/clients -> `ocx connect status`, sync -> `ocx sync`, shim -> the client diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0dc6bec9f2c..78fd2d90802 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "macos-build-script.test.ts": "gui", "server-combo-held-response.test.ts": "server", "key-attribution.test.ts": "usage", "provider-send-path-import.test.ts": "server", @@ -21,6 +22,7 @@ "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", @@ -1413,6 +1415,8 @@ "web-search-sidecar-429.test.ts": "web-search", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "usage-timeline.test.ts": "usage", + "companion-settings.test.ts": "server", "chat-tool-choice-allowed-tools.test.ts": "responses", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "google-strict-tool-validated-mode.test.ts": "adapters/google", diff --git a/tests/gui/macos-build-script.test.ts b/tests/gui/macos-build-script.test.ts new file mode 100644 index 00000000000..70d4a018e7f --- /dev/null +++ b/tests/gui/macos-build-script.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { repoPath, repoRoot as findRepoRoot } from "../helpers/repo-root"; + +// The macOS build script deletes whatever sits at its destination, so its containment +// check is a safety boundary rather than a convenience. These run the real script. +// +// Every case here shipped as a defect at some point: +// - the original check compared two values derived from the same variable, so any +// OUTPUT_DIR passed; +// - resolving logical paths let a repository-local symlink point outside; +// - re-appending an unresolved tail let `.nope/../../outside` escape entirely; +// - resolving physically BEFORE normalising let `..` reveal a symlink that was then +// never followed. + +const repoRoot = findRepoRoot(); +const script = repoPath("scripts", "build-macos-app.sh"); +const isMacOS = process.platform === "darwin"; + +const scriptText = await Bun.file(script).text(); +const packageText = await Bun.file(repoPath("app", "Package.swift")).text(); + +async function runScript(outputDir: string, cwd: string = repoRoot) { + const proc = Bun.spawn(["bash", script], { + cwd, + env: { ...process.env, OUTPUT_DIR: outputDir }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([ + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stderr, exitCode }; +} + +/// Runs `body` with a uniquely named sandbox that this test owns and always removes. +/// +/// An earlier version deleted FIXED paths such as `/ocx-escaped-probe`, +/// which would have destroyed unrelated data if anything already lived there. A test +/// for a safety boundary must not itself be destructive. +async function withSandbox(body: (sandbox: string) => Promise): Promise { + const sandbox = mkdtempSync(join(tmpdir(), "ocx-containment-")); + try { + return await body(sandbox); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } +} + +describe.skipIf(!isMacOS)("macOS build script containment", () => { + test("refuses a destination outside the repository and creates nothing", async () => { + // Deliberately NOT derived from process.env.HOME: other suites replace HOME with a + // temp directory, and temp is a permitted root — so this test built successfully and + // failed during a full-suite run. A sibling of the repository is stable and is + // outside every permitted root. + const target = resolve(repoRoot, "..", `.ocx-outside-${process.pid}-${Date.now()}`); + + const { stderr, exitCode } = await runScript(target); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Refusing to build"); + expect(existsSync(target)).toBe(false); + }, 120_000); + + test("refuses an unresolved .. traversal before creating any directory", async () => { + const intermediate = join(repoRoot, `.ocx-traversal-${process.pid}`); + const escapedName = `.ocx-escaped-${process.pid}-${Date.now()}`; + const escaped = resolve(repoRoot, "..", escapedName); + + // String concatenation, NOT path.join: join() normalises `..` itself, so the script + // would never receive the traversal that was the actual bypass. Written with join() + // this test passed against the broken resolver. + const traversal = `${intermediate}/../../${escapedName}`; + + const { stderr, exitCode } = await runScript(traversal); + + expect(exitCode).not.toBe(0); + // The message names the RESOLVED path, which is the proof normalisation happened. + expect(stderr).toContain(escapedName); + expect(stderr).toContain("Refusing to build into"); + expect(existsSync(escaped)).toBe(false); + expect(existsSync(intermediate)).toBe(false); + }, 120_000); + + test("follows a symlink revealed by a .. traversal instead of trusting the link path", async () => { + const link = join(repoRoot, `.ocx-link-${process.pid}`); + const missing = join(repoRoot, `.ocx-missing-${process.pid}`); + + const { stderr, exitCode } = await withSandbox(async (sandbox) => { + const outside = join(sandbox, "outside-target"); + rmSync(link, { recursive: true, force: true }); + symlinkSync(outside, link); + try { + // Nothing exists at the missing component, so `..` has to be applied lexically + // before the symlink can be resolved. + return await runScript(`${missing}/../${link.split("/").pop()}`); + } finally { + rmSync(link, { recursive: true, force: true }); + rmSync(missing, { recursive: true, force: true }); + } + }); + + // The link points at a directory that does not exist, so the script refuses to + // build THROUGH it rather than guessing where it leads. What must never happen is + // treating the unresolved link path as a destination inside the repository. + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Refusing to build"); + expect(stderr).not.toContain(`${missing}/`); + expect(existsSync(link)).toBe(false); + expect(existsSync(missing)).toBe(false); + }, 300_000); + + test("refuses a symlink that points outside the permitted roots", async () => { + const link = join(repoRoot, `.ocx-outward-${process.pid}`); + const outside = join( + process.env.HOME ?? "/Users/shared", + `.ocx-symtarget-${process.pid}-${Date.now()}`, + ); + + rmSync(link, { recursive: true, force: true }); + symlinkSync(outside, link); + try { + const { stderr, exitCode } = await runScript(link); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Refusing to build"); + expect(existsSync(outside)).toBe(false); + } finally { + rmSync(link, { recursive: true, force: true }); + } + }, 120_000); + + // The third bypass: a RELATIVE dangling target was joined onto the resolved prefix + // without normalising, so `link -> ../../outside` became `/../../outside`, + // satisfied the `/*` prefix check, and escaped during mkdir -p. + test("refuses a symlink whose relative target escapes the repository", async () => { + const link = join(repoRoot, `.ocx-rel-${process.pid}`); + const escaped = resolve(repoRoot, "..", "..", `ocx-rel-target-${process.pid}`); + + rmSync(link, { recursive: true, force: true }); + symlinkSync(`../../ocx-rel-target-${process.pid}`, link); + try { + const { stderr, exitCode } = await runScript(link); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Refusing to build"); + expect(existsSync(escaped)).toBe(false); + } finally { + rmSync(link, { recursive: true, force: true }); + } + }, 120_000); + + // Runs the child in a directory that CONTAINS a matching entry, so the old unquoted + // loop would have expanded the star. With cwd=repoRoot and the glob under dist/, the + // pattern matched nothing and the test passed against the broken implementation too. + test("treats glob characters as literal path components", async () => { + await withSandbox(async (sandbox) => { + const decoy = join(sandbox, "ocx-glob-decoy-probe"); + mkdirSync(decoy, { recursive: true }); + + const { stderr } = await runScript(join(sandbox, "ocx-glob-*-probe"), sandbox); + + expect(stderr).not.toContain("Refusing to build"); + // The literal-star path is the one that was used, not the decoy it could match. + expect(existsSync(join(sandbox, "ocx-glob-*-probe"))).toBe(true); + expect(existsSync(join(decoy, "OpenCodex.app"))).toBe(false); + }); + }, 300_000); + + test("allows a destination inside the repository", async () => { + const inside = join(repoRoot, "dist", `ocx-inside-${process.pid}`); + try { + const { stderr } = await runScript(inside); + expect(stderr).not.toContain("Refusing to build into"); + } finally { + rmSync(inside, { recursive: true, force: true }); + } + }, 300_000); + + test("allows a temp destination", async () => { + await withSandbox(async (sandbox) => { + const { stderr } = await runScript(join(sandbox, "build")); + expect(stderr).not.toContain("Refusing to build into"); + }); + }, 300_000); +}); + +describe("macOS widget packaging", () => { + test("stages, signs, and validates the WidgetKit appex", () => { + expect(scriptText).toContain("--product OpenCodexWidget"); + expect(scriptText).toContain("Contents/PlugIns/OpenCodexWidget.appex"); + expect(scriptText).toContain("Widget-Info.plist"); + expect(scriptText).toContain("Widget.entitlements"); + expect(scriptText).toContain("Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget"); + expect(scriptText).toContain("container fallback path"); + expect(packageText).toContain("_NSExtensionMain"); + }); +}); diff --git a/tests/server/companion-settings.test.ts b/tests/server/companion-settings.test.ts new file mode 100644 index 00000000000..de0058dc861 --- /dev/null +++ b/tests/server/companion-settings.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyCompanionSettingsPatch, + DEFAULT_COMPANION_SETTINGS, + loadCompanionSettings, + saveCompanionSettings, +} from "../../src/companion/settings"; +import { resetCompanionPresenceForTests } from "../../src/server/management/companion-routes"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; + +const config = { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; +async function withHome(run: (home: string) => Promise | T): Promise { + const home = mkdtempSync(join(tmpdir(), "ocx-companion-")); + const old = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { return await run(home); } finally { + if (old === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = old; + rmSync(home, { recursive: true, force: true }); + } +} +async function call(method: string, body?: unknown, userAgent?: string): Promise<{ status: number; body: any }> { + const url = new URL("http://127.0.0.1:10100/api/companion/settings"); + const req = new Request(url, { + method, + headers: { + host: "127.0.0.1:10100", + ...(userAgent ? { "user-agent": userAgent } : {}), + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const response = await handleManagementAPI(req, url, config, {}, "admin-token"); + return { status: response?.status ?? 404, body: response ? await response.json() : null }; +} + +describe("companion settings", () => { + test("defaults, corrupt files, validation, and roundtrip persistence", async () => { + await withHome(home => { + expect(loadCompanionSettings().settings).toEqual(DEFAULT_COMPANION_SETTINGS); + writeFileSync(join(home, "companion.json"), "{"); + expect(loadCompanionSettings().settings).toEqual(DEFAULT_COMPANION_SETTINGS); + expect(loadCompanionSettings().corrupt).toBe(true); + expect(applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { unknown: true })).toEqual({ error: expect.any(String) }); + expect(applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { menuBarTemplate: "x".repeat(201) })).toEqual({ error: expect.any(String) }); + const updated = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { showChart: false }); + if ("error" in updated) throw new Error(updated.error); + saveCompanionSettings(updated); + expect(loadCompanionSettings().settings.showChart).toBe(false); + }); + }); + + test("GET, PUT, and reset are routed", async () => { + await withHome(async () => { + expect((await call("GET")).status).toBe(200); + expect((await call("PUT", { settings: { showToday: false } })).body.settings.showToday).toBe(false); + expect((await call("PUT", { reset: true })).body.settings).toEqual(DEFAULT_COMPANION_SETTINGS); + expect((await call("PUT", { settings: { bad: true } })).status).toBe(400); + }); + }); + + test("GET reports corrupt persisted settings without overwriting them", async () => { + await withHome(async home => { + writeFileSync(join(home, "companion.json"), "{"); + const result = await call("GET"); + expect(result.status).toBe(200); + expect(result.body.corrupt).toBe(true); + expect(readFileSync(join(home, "companion.json"), "utf8")).toBe("{"); + }); + }); + + test("GET records menu bar presence only for the companion user agent", async () => { + await withHome(async () => { + resetCompanionPresenceForTests(); + const initial = await call("GET"); + expect(initial.body.companion.lastSeenAt).toBeNull(); + const ordinary = await call("GET", undefined, "Mozilla/5.0"); + expect(ordinary.body.companion.lastSeenAt).toBeNull(); + const companion = await call("GET", undefined, "OpenCodexMenuBar/2.60.0"); + expect(companion.body.companion.lastSeenAt).toBeNumber(); + }); + }); +}); diff --git a/tests/usage/usage-timeline.test.ts b/tests/usage/usage-timeline.test.ts new file mode 100644 index 00000000000..ee395007e1e --- /dev/null +++ b/tests/usage/usage-timeline.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { createTimelineAccumulator, parseTimelineQuery } from "../../src/usage/timeline"; + +const now = 1_700_000_000_000; +function entry(overrides: Partial = {}): PersistedUsageEntry { + return { + requestId: "request", + timestamp: now - 30 * 60_000, + provider: "openai", + model: "gpt-5", + status: 200, + durationMs: 1, + usageStatus: "reported", + ...overrides, + }; +} +function attempt(totalTokens: number, ordinal: number): NonNullable[number] { + return { + ordinal, + provider: "openai", + model: "gpt-5", + adapter: "test", + status: 200, + durationMs: 1, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + totalTokens, + }; +} + +describe("usage timeline", () => { + test("parses defaults and rejects invalid values", () => { + expect(parseTimelineQuery(new URLSearchParams(), now)).toMatchObject({ + hours: 24, bucketMinutes: 60, metric: "total", aggregation: "sum", grouping: "model", models: null, + }); + expect(parseTimelineQuery(new URLSearchParams("hours=7"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("bucketMinutes=0"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("metric=nope"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("models=openai%2Fgpt-5%2Cbad"), now)).toEqual({ error: expect.any(String) }); + }); + + test("buckets timestamps and attributes attempts without parent double counting", () => { + const query = parseTimelineQuery(new URLSearchParams("hours=6&bucketMinutes=60"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ + requestId: "retry", + totalTokens: 999, + attempts: [ + attempt(10, 0), + attempt(20, 1), + ], + })); + const result = acc.finish(); + expect(result.series[0]?.total).toBe(30); + expect(result.buckets).toBe(6); + }); + + test("supports request average and max", () => { + const make = (aggregation: "sum" | "average" | "max") => { + const query = parseTimelineQuery(new URLSearchParams(`hours=6&aggregation=${aggregation}`), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ requestId: "a", totalTokens: 10 })); + acc.add(entry({ requestId: "b", totalTokens: 30 })); + return acc.finish().series[0]?.total; + }; + expect(make("sum")).toBe(40); + expect(make("average")).toBe(20); + expect(make("max")).toBe(30); + }); + + test("filters plotted models but keeps available models and supports accounts", () => { + const query = parseTimelineQuery(new URLSearchParams("models=openai%2Fone&grouping=modelAccount"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ model: "one", accountLogLabel: "main", totalTokens: 4 })); + acc.add(entry({ model: "two", totalTokens: 8 })); + const result = acc.finish(); + expect(result.availableModels).toEqual(["openai/one", "openai/two"]); + expect(result.series[0]?.id).toBe("openai/one · main"); + }); + + test("counts missing measurements and folds excess series", () => { + const query = parseTimelineQuery(new URLSearchParams("hours=6&metric=input"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ usage: undefined, totalTokens: 1 })); + for (let index = 0; index < 25; index += 1) { + acc.add(entry({ model: `model-${index}`, usage: { inputTokens: index } })); + } + const result = acc.finish(); + expect(result.missingMeasurements).toBe(1); + expect(result.series).toHaveLength(24); + expect(result.series.at(-1)?.id).toBe("other"); + }); + + test("folds other rows with request-level max and average", () => { + const make = (aggregation: "average" | "max") => { + const query = parseTimelineQuery(new URLSearchParams(`hours=6&aggregation=${aggregation}`), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + for (let index = 0; index < 25; index += 1) { + acc.add(entry({ + requestId: `request-${index}`, + model: `model-${index}`, + totalTokens: index < 23 ? 100 + index : index - 22, + })); + } + return acc.finish().series.at(-1); + }; + expect(make("max")?.points.at(-1)).toBe(2); + expect(make("average")?.points.at(-1)).toBe(1.5); + }); +}); From 2ff7f3385d83c05279093eb9c7bdd5d5963d9605 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 20:12:01 +0900 Subject: [PATCH 18/87] feat(desktop): land the desktop application stack on dev (#5318) Consolidates the desktop stack that was carried as a GitHub native stack on top of the macOS menu bar companion. #5196 landed as 38a5ab9fc4 by squash, which detached every child in the chain from its base, so the remaining work is applied here as one branch against the current dev instead of replayed through bases that no longer exist. Carries the standalone binary, the Tauri v2 cross-platform tray and webview shell, the GUI desktop shell integration, the WidgetKit appex bundle, and the signed desktop packaging for DMG, MSI, AppImage and deb. Co-authored-by: codex Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 92 +- .github/workflows/release.yml | 253 +- .gitignore | 5 + AGENTS.md | 6 +- README.md | 13 +- app/Info.plist | 42 - app/Package.swift | 23 +- app/Sources/IconProbe/main.swift | 32 - app/Sources/MenuBarApp/main.swift | 11 - app/Sources/MenuBarCore/Discovery.swift | 2 +- .../MenuBarCore/PollingCoordinator.swift | 2 +- app/Sources/MenuBarCore/ProxyClient.swift | 2 +- app/Sources/MenuBarCore/WidgetSnapshot.swift | 2 +- .../MenuBarCoreTests/TransportSuite.swift | 2 +- app/Sources/MenuBarUI/AppDelegate.swift | 254 - app/Sources/MenuBarUI/CompanionViews.swift | 78 - app/Sources/MenuBarUI/PopoverPanel.swift | 170 - .../MenuBarUI/PopoverViewController.swift | 384 -- app/Sources/MenuBarUI/ProviderListView.swift | 255 - app/Sources/MenuBarUI/StatusIcon.swift | 73 - app/Sources/MenuBarUI/Theme.swift | 103 - app/Sources/MenuBarUI/TimelineChartView.swift | 135 - app/Sources/MenuBarUI/Views.swift | 264 - app/Sources/MenuBarUITests/Harness.swift | 105 - app/Sources/MenuBarUITests/main.swift | 165 - app/Sources/OpenCodexWidget/Views.swift | 4 +- app/Sources/UIProbe/main.swift | 165 - app/Widget-Info.plist | 2 +- desktop/README.md | 69 + desktop/package.json | 13 + desktop/scripts/build-widget.sh | 76 + desktop/scripts/collect-release-assets.ts | 105 + desktop/scripts/prepare-sidecar.ts | 59 + desktop/scripts/updater-manifest.ts | 98 + desktop/src-tauri/Cargo.lock | 5654 +++++++++++++++++ desktop/src-tauri/Cargo.toml | 35 + desktop/src-tauri/build.rs | 3 + desktop/src-tauri/capabilities/default.json | 14 + desktop/src-tauri/icons/128x128.png | Bin 0 -> 2849 bytes desktop/src-tauri/icons/128x128@2x.png | Bin 0 -> 6300 bytes desktop/src-tauri/icons/32x32.png | Bin 0 -> 638 bytes desktop/src-tauri/icons/64x64.png | Bin 0 -> 1176 bytes desktop/src-tauri/icons/Square107x107Logo.png | Bin 0 -> 2268 bytes desktop/src-tauri/icons/Square142x142Logo.png | Bin 0 -> 3346 bytes desktop/src-tauri/icons/Square150x150Logo.png | Bin 0 -> 3655 bytes desktop/src-tauri/icons/Square284x284Logo.png | Bin 0 -> 7155 bytes desktop/src-tauri/icons/Square30x30Logo.png | Bin 0 -> 559 bytes desktop/src-tauri/icons/Square310x310Logo.png | Bin 0 -> 7822 bytes desktop/src-tauri/icons/Square44x44Logo.png | Bin 0 -> 845 bytes desktop/src-tauri/icons/Square71x71Logo.png | Bin 0 -> 1453 bytes desktop/src-tauri/icons/Square89x89Logo.png | Bin 0 -> 1716 bytes desktop/src-tauri/icons/StoreLogo.png | Bin 0 -> 894 bytes desktop/src-tauri/icons/icon.icns | Bin 0 -> 65470 bytes desktop/src-tauri/icons/icon.ico | Bin 0 -> 11591 bytes desktop/src-tauri/icons/icon.png | Bin 0 -> 13516 bytes desktop/src-tauri/icons/tray/icon.png | Bin 0 -> 3433 bytes desktop/src-tauri/src/auth.rs | 31 + desktop/src-tauri/src/discovery.rs | 116 + desktop/src-tauri/src/formatting.rs | 79 + desktop/src-tauri/src/lib.rs | 119 + desktop/src-tauri/src/logging.rs | 14 + desktop/src-tauri/src/main.rs | 3 + desktop/src-tauri/src/proxy.rs | 111 + desktop/src-tauri/src/sidecar.rs | 47 + desktop/src-tauri/src/tray.rs | 351 + desktop/src-tauri/src/updater.rs | 71 + desktop/src-tauri/src/widget.rs | 495 ++ desktop/src-tauri/src/window.rs | 88 + desktop/src-tauri/tauri.conf.json | 71 + desktop/ui/index.html | 29 + desktop/ui/main.js | 34 + docs-site/astro.config.mjs | 1 + .../docs/fr/reference/cli/lifecycle.md | 1 + .../docs/getting-started/installation.md | 13 + .../docs/getting-started/quickstart.md | 5 + .../src/content/docs/guides/desktop-app.md | 84 + .../src/content/docs/guides/macos-menu-bar.md | 32 +- .../docs/ja/getting-started/installation.md | 13 + .../docs/ja/getting-started/quickstart.md | 5 + .../content/docs/ja/guides/macos-menu-bar.md | 32 +- .../docs/ja/reference/cli/lifecycle.md | 1 + .../docs/ko/getting-started/installation.md | 13 + .../docs/ko/getting-started/quickstart.md | 5 + .../content/docs/ko/guides/macos-menu-bar.md | 31 +- .../docs/ko/reference/cli/lifecycle.md | 2 + .../content/docs/reference/cli/lifecycle.md | 2 + .../docs/ru/getting-started/installation.md | 13 + .../docs/ru/getting-started/quickstart.md | 5 + .../content/docs/ru/guides/macos-menu-bar.md | 32 +- .../docs/ru/reference/cli/lifecycle.md | 2 + .../docs/tr/reference/cli/lifecycle.md | 2 + .../zh-cn/getting-started/installation.md | 12 + .../docs/zh-cn/getting-started/quickstart.md | 5 + .../docs/zh-cn/guides/macos-menu-bar.md | 29 +- .../docs/zh-cn/reference/cli/lifecycle.md | 1 + .../docs/zh-tw/reference/cli/lifecycle.md | 1 + gui/src/App.tsx | 18 + gui/src/i18n/de.ts | 25 +- gui/src/i18n/en.ts | 25 +- gui/src/i18n/fr.ts | 25 +- gui/src/i18n/ja.ts | 25 +- gui/src/i18n/ko.ts | 25 +- gui/src/i18n/ru.ts | 25 +- gui/src/i18n/tr.ts | 25 +- gui/src/i18n/vi.ts | 25 +- gui/src/i18n/zh-TW.ts | 25 +- gui/src/i18n/zh.ts | 25 +- gui/src/lib/desktop-shell.ts | 32 + gui/src/pages/usage-companion-panel.tsx | 147 +- gui/src/pages/usage-companion-utils.ts | 1 + gui/tests/desktop-shell.test.ts | 37 + package.json | 5 +- readme/README.fr.md | 5 +- readme/README.ja.md | 4 +- readme/README.ko.md | 4 +- readme/README.ru.md | 5 +- readme/README.tr.md | 4 +- readme/README.zh-CN.md | 4 +- readme/README.zh-TW.md | 4 +- readme/i18n-manifest.json | 14 +- scripts/build-macos-app.sh | 268 - scripts/build-standalone.ts | 53 + scripts/package-macos-release.sh | 113 - scripts/test-layout/layout.json | 6 +- src/cli/help.ts | 10 +- src/cli/registry.ts | 3 +- src/client/machine-listener.ts | 7 +- src/lib/bun-runtime.ts | 8 +- src/lib/package-tree-integrity.ts | 3 +- src/lib/package-version.ts | 8 + src/lib/standalone.ts | 16 + src/lib/winsw.ts | 4 +- src/server/gui-static.ts | 14 +- src/server/management-api.ts | 11 +- src/server/management/companion-routes.ts | 31 +- src/server/management/route-registry.ts | 1 + src/server/responses/codex-ws-wire.ts | 10 +- src/service/health.ts | 5 +- src/service/launchd.ts | 2 +- src/service/state.ts | 11 +- src/service/windows-taskxml.ts | 21 +- src/tray/windows-tray.ps1 | 2 +- src/update/index.ts | 7 +- structure/INDEX.md | 3 + structure/desktop-shell.md | 50 + structure/gui-and-management-api.md | 5 + structure/manifest.json | 26 +- structure/ops/docs-and-release.md | 4 + structure/ops/service-and-sidecars.md | 5 + structure/overview.md | 8 +- tests/ci-workflows/ci-structure-gate.test.ts | 15 +- tests/ci-workflows/ci-workflows.test.ts | 1 + .../release-desktop-scripts.test.ts | 199 + tests/fixtures/test-layout-expected.json | 6 +- tests/gui/gui-desktop-sidecar-script.test.ts | 18 + tests/gui/gui-static.test.ts | 13 + tests/gui/macos-build-script.test.ts | 201 - tests/gui/standalone-build-script.test.ts | 18 + tests/lib/standalone.test.ts | 22 + tests/server/companion-settings.test.ts | 41 +- tests/service/standalone-service.test.ts | 26 + 161 files changed, 9441 insertions(+), 3139 deletions(-) delete mode 100644 app/Info.plist delete mode 100644 app/Sources/IconProbe/main.swift delete mode 100644 app/Sources/MenuBarApp/main.swift delete mode 100644 app/Sources/MenuBarUI/AppDelegate.swift delete mode 100644 app/Sources/MenuBarUI/CompanionViews.swift delete mode 100644 app/Sources/MenuBarUI/PopoverPanel.swift delete mode 100644 app/Sources/MenuBarUI/PopoverViewController.swift delete mode 100644 app/Sources/MenuBarUI/ProviderListView.swift delete mode 100644 app/Sources/MenuBarUI/StatusIcon.swift delete mode 100644 app/Sources/MenuBarUI/Theme.swift delete mode 100644 app/Sources/MenuBarUI/TimelineChartView.swift delete mode 100644 app/Sources/MenuBarUI/Views.swift delete mode 100644 app/Sources/MenuBarUITests/Harness.swift delete mode 100644 app/Sources/MenuBarUITests/main.swift delete mode 100644 app/Sources/UIProbe/main.swift create mode 100644 desktop/README.md create mode 100644 desktop/package.json create mode 100755 desktop/scripts/build-widget.sh create mode 100644 desktop/scripts/collect-release-assets.ts create mode 100644 desktop/scripts/prepare-sidecar.ts create mode 100644 desktop/scripts/updater-manifest.ts create mode 100644 desktop/src-tauri/Cargo.lock create mode 100644 desktop/src-tauri/Cargo.toml create mode 100644 desktop/src-tauri/build.rs create mode 100644 desktop/src-tauri/capabilities/default.json create mode 100644 desktop/src-tauri/icons/128x128.png create mode 100644 desktop/src-tauri/icons/128x128@2x.png create mode 100644 desktop/src-tauri/icons/32x32.png create mode 100644 desktop/src-tauri/icons/64x64.png create mode 100644 desktop/src-tauri/icons/Square107x107Logo.png create mode 100644 desktop/src-tauri/icons/Square142x142Logo.png create mode 100644 desktop/src-tauri/icons/Square150x150Logo.png create mode 100644 desktop/src-tauri/icons/Square284x284Logo.png create mode 100644 desktop/src-tauri/icons/Square30x30Logo.png create mode 100644 desktop/src-tauri/icons/Square310x310Logo.png create mode 100644 desktop/src-tauri/icons/Square44x44Logo.png create mode 100644 desktop/src-tauri/icons/Square71x71Logo.png create mode 100644 desktop/src-tauri/icons/Square89x89Logo.png create mode 100644 desktop/src-tauri/icons/StoreLogo.png create mode 100644 desktop/src-tauri/icons/icon.icns create mode 100644 desktop/src-tauri/icons/icon.ico create mode 100644 desktop/src-tauri/icons/icon.png create mode 100644 desktop/src-tauri/icons/tray/icon.png create mode 100644 desktop/src-tauri/src/auth.rs create mode 100644 desktop/src-tauri/src/discovery.rs create mode 100644 desktop/src-tauri/src/formatting.rs create mode 100644 desktop/src-tauri/src/lib.rs create mode 100644 desktop/src-tauri/src/logging.rs create mode 100644 desktop/src-tauri/src/main.rs create mode 100644 desktop/src-tauri/src/proxy.rs create mode 100644 desktop/src-tauri/src/sidecar.rs create mode 100644 desktop/src-tauri/src/tray.rs create mode 100644 desktop/src-tauri/src/updater.rs create mode 100644 desktop/src-tauri/src/widget.rs create mode 100644 desktop/src-tauri/src/window.rs create mode 100644 desktop/src-tauri/tauri.conf.json create mode 100644 desktop/ui/index.html create mode 100644 desktop/ui/main.js create mode 100644 docs-site/src/content/docs/guides/desktop-app.md create mode 100644 gui/src/lib/desktop-shell.ts create mode 100644 gui/tests/desktop-shell.test.ts delete mode 100755 scripts/build-macos-app.sh create mode 100644 scripts/build-standalone.ts delete mode 100755 scripts/package-macos-release.sh create mode 100644 src/lib/package-version.ts create mode 100644 src/lib/standalone.ts create mode 100644 structure/desktop-shell.md create mode 100644 tests/ci-workflows/release-desktop-scripts.test.ts create mode 100644 tests/gui/gui-desktop-sidecar-script.test.ts delete mode 100644 tests/gui/macos-build-script.test.ts create mode 100644 tests/gui/standalone-build-script.test.ts create mode 100644 tests/lib/standalone.test.ts create mode 100644 tests/service/standalone-service.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5eacaa1e9e..3dbb2f7dede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ on: - "tests/**" - "scripts/**" - "app/**" + - "desktop/**" - "gui/**" - "assets/**" - ".gitattributes" @@ -215,6 +216,7 @@ jobs: - 'tests/**' - 'scripts/**' - 'app/**' + - 'desktop/**' - 'gui/**' - 'assets/**' - '.gitattributes' @@ -1149,12 +1151,12 @@ jobs: # `if: always()` is load-bearing. Without it, a failed or skipped dependency # skips this job too — and GitHub reports a skipped job as success, so the gate # would go green precisely when something went wrong. - macos-app: - name: macos app + widget: + name: macos widget + bundle needs: [changes, gates] if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' runs-on: macos-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -1167,13 +1169,80 @@ jobs: bun-version: 1.3.14 - name: Install dependencies - run: bun install --frozen-lockfile + run: | + bun install --frozen-lockfile + cd desktop + bun install --frozen-lockfile + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable - - name: Test macOS menu bar app + - name: Test MenuBarCore run: bun run test:macos - - name: Build macOS menu bar app - run: bun run build:macos + - name: Build dashboard + run: bun run build:gui + + - name: Prepare desktop sidecar + run: bun desktop/scripts/prepare-sidecar.ts + + - name: Build WidgetKit appex + run: bash desktop/scripts/build-widget.sh + + - name: Build unsigned desktop app + working-directory: desktop + run: bunx tauri build --ci --bundles app + + - name: Verify WidgetKit appex and desktop app + run: | + app=desktop/src-tauri/target/release/bundle/macos/OpenCodex.app + test -x "$app/Contents/MacOS/OpenCodex" + test -x "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" + test -x "$app/Contents/MacOS/ocx" + codesign -dv "$app/Contents/PlugIns/OpenCodexWidget.appex" + + desktop-shell: + name: desktop shell + needs: [changes, gates] + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Install Tauri Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + components: rustfmt, clippy + + - name: Prepare desktop check resources + run: | + set -euo pipefail + triple="$(rustc -vV | sed -n 's/^host: //p')" + mkdir -p desktop/src-tauri/binaries desktop/src-tauri/resources/gui/dist + : > "desktop/src-tauri/binaries/ocx-${triple}" + chmod +x "desktop/src-tauri/binaries/ocx-${triple}" + : > desktop/src-tauri/resources/gui/dist/.keep + + - name: Check Rust formatting + run: cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check + + - name: Run Rust clippy + run: cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings + + - name: Run Rust tests + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml ci: name: ci @@ -1182,7 +1251,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, macos-app] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, widget, desktop-shell] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1248,12 +1317,15 @@ jobs: GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" - GATED_JOBS="$GATED_JOBS structure-gate macos-app" + GATED_JOBS="$GATED_JOBS structure-gate widget" + GATED_JOBS="$GATED_JOBS desktop-shell" expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|macos-app) + test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|widget) + echo "$scoped" ;; + desktop-shell) echo "$scoped" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c3c2002583..dd63e98a51e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,61 +69,258 @@ jobs: process.exit(1); } NODE - package-macos: + package-standalone: needs: validate-dispatch - runs-on: macos-latest - timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: bun-linux-x64 + smoke: true + - os: macos-latest + target: bun-darwin-arm64 + smoke: true + - os: macos-latest + target: bun-darwin-x64 + smoke: false + - os: windows-latest + target: bun-windows-x64 + smoke: true + - os: ubuntu-latest + target: bun-linux-arm64 + smoke: false + runs-on: ${{ matrix.os }} + timeout-minutes: 25 permissions: contents: read - outputs: - archive_name: ${{ steps.package.outputs.archive_name }} - checksum_name: ${{ steps.package.outputs.checksum_name }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - - name: Package the macOS companion - id: package + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build dashboard + run: bun run build:gui + + - name: Build standalone binary + run: bun run build:standalone --target ${{ matrix.target }} + + - name: Smoke test standalone binary + if: matrix.smoke && runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + binary="dist/standalone/${{ matrix.target }}/ocx" + "$binary" --version + OPENCODEX_HOME="$RUNNER_TEMP/ocx-home" "$binary" start --port 10177 >"$RUNNER_TEMP/ocx.log" 2>&1 & + pid=$! + trap 'kill "$pid" 2>/dev/null || true' EXIT + for _ in $(seq 1 30); do curl -fsS http://127.0.0.1:10177/healthz && break || sleep 1; done + curl -fsS http://127.0.0.1:10177/healthz + test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:10177/)" = 200 + + - name: Smoke test standalone binary (Windows) + if: matrix.smoke && runner.os == 'Windows' + shell: pwsh + run: | + $binary = "dist/standalone/${{ matrix.target }}/ocx.exe" + & $binary --version + $env:OPENCODEX_HOME = Join-Path $env:RUNNER_TEMP "ocx-home" + $process = Start-Process -FilePath $binary -ArgumentList "start", "--port", "10177" -PassThru + try { + for ($i = 0; $i -lt 30; $i++) { + try { Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Out-Null; break } catch { Start-Sleep -Seconds 1 } + } + Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Select-Object -ExpandProperty Content + Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/ | Out-Null + } finally { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } + + - name: Archive standalone release + shell: bash env: RELEASE_VERSION: ${{ inputs.version }} - UNIVERSAL: "1" - # A monotonic numeric CFBundleVersion. Preview versions carry a suffix that - # Apple does not accept in that field, so the script uses the numeric core - # plus this run number. - MACOS_BUILD_NUMBER: ${{ github.run_number }} - # NOTE: intentionally no MACOS_SIGN_IDENTITY here. The build script honours - # it, but an identity NAME alone cannot sign on a hosted runner — the - # certificate and private key are never imported into a keychain, so codesign - # fails with "no identity found". Real Developer ID signing needs a protected - # P12 import, a temporary keychain, notarytool credentials, and stapling, all - # as one security-reviewed change. Until then the asset is ad-hoc signed and - # the docs carry the Gatekeeper first-launch path. - run: bash scripts/package-macos-release.sh - - - name: Upload the release asset + STANDALONE_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + cd "dist/standalone/$STANDALONE_TARGET" + if [[ "$RUNNER_OS" == "Windows" ]]; then + powershell -NoProfile -Command 'Compress-Archive -Path ocx.exe,gui -DestinationPath ("../../ocx-{0}-{1}.zip" -f $env:RELEASE_VERSION,$env:STANDALONE_TARGET) -Force' + else + tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui + fi + cd ../../.. + if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + else sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + fi + + - name: Upload standalone release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: macos-release + name: standalone-${{ matrix.target }} + path: | + dist/ocx-*.tar.gz + dist/ocx-*.zip + dist/ocx-*.sha256 + if-no-files-found: error + retention-days: 7 + + package-desktop: + needs: validate-dispatch + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: universal-apple-darwin + bundles: app,dmg + sidecar-targets: macos + artifact-suffixes: macos.dmg,macos.app.tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + bundles: msi + sidecar-targets: x86_64-pc-windows-msvc + artifact-suffixes: windows-x64.msi + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + bundles: appimage,deb + sidecar-targets: x86_64-unknown-linux-gnu + artifact-suffixes: linux-x86_64.AppImage,linux-amd64.deb + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install project dependencies + run: bun install --frozen-lockfile + + - name: Build dashboard + run: bun run build:gui + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev + + - name: Install desktop dependencies + working-directory: desktop + run: bun install --frozen-lockfile + + - name: Prepare macOS sidecars + if: runner.os == 'macOS' + run: | + bun desktop/scripts/prepare-sidecar.ts --target aarch64-apple-darwin + bun desktop/scripts/prepare-sidecar.ts --target x86_64-apple-darwin + + - name: Prepare sidecar + if: runner.os != 'macOS' + run: bun desktop/scripts/prepare-sidecar.ts --target ${{ matrix.sidecar-targets }} + + - name: Build WidgetKit extension + if: runner.os == 'macOS' + run: bash desktop/scripts/build-widget.sh + + # Release signing is intentionally secret-gated. Developer ID, notarization, + # and updater signatures require maintainer-owned credentials; builds without + # those secrets remain useful for local validation but are not release assets. + - name: Build desktop bundles + working-directory: desktop + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} + + - name: Rename release assets + env: + RELEASE_VERSION: ${{ inputs.version }} + DESKTOP_TARGET: ${{ matrix.target }} + run: | + bun desktop/scripts/collect-release-assets.ts \ + --version "$RELEASE_VERSION" \ + --target "$DESKTOP_TARGET" \ + --out dist/release + + - name: Upload desktop release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-${{ matrix.target }} path: dist/release/ if-no-files-found: error retention-days: 7 - attach-macos: + attach-release: runs-on: ubuntu-latest - needs: [publish, package-macos] + needs: [publish, package-standalone, package-desktop] if: ${{ inputs.dry-run != true }} + env: + UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} timeout-minutes: 10 permissions: contents: write steps: - - name: Download the packaged asset + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download standalone packaged assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: macos-release + pattern: standalone-* + merge-multiple: true path: dist/release + - name: Download desktop packaged assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-* + merge-multiple: true + path: dist/release + + # Generate latest.json only when the updater key is configured; then require + # signatures for all four updater platforms before publishing it. + - name: Generate updater manifest + if: env.UPDATER_SIGNING_CONFIGURED == 'true' + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + bun desktop/scripts/updater-manifest.ts \ + --version "$RELEASE_VERSION" \ + --dir dist/release \ + --repo lidge-jun/opencodex \ + --out dist/release/latest.json \ + --require-all + - name: Verify the checksum before uploading run: | cd dist/release diff --git a/.gitignore b/.gitignore index e6dd44882e5..8f9c5f9fd59 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,8 @@ go/ native/**/target/ dist/macos/ dist/release/ +desktop/src-tauri/binaries/ +desktop/src-tauri/resources/ +desktop/src-tauri/widget/ +desktop/src-tauri/gen/ +desktop/src-tauri/target/ diff --git a/AGENTS.md b/AGENTS.md index 469912b5e39..5fc447e7c3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,8 @@ Bun-native TypeScript with no separate server compile step. seeds in `layout.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. -- `app/` — native macOS menu bar companion (Swift + AppKit, no third-party - dependencies). `MenuBarCore` is the testable transport/model layer, - `MenuBarUI` the AppKit views, `MenuBarApp` the entry point. Its tests are +- `app/` — native macOS WidgetKit extension bundled into the Tauri desktop app; + `MenuBarCore` is its snapshot model/formatting layer. Its tests are executables, not XCTest bundles — Command Line Tools ships neither a usable XCTest module nor the swift-testing runtime. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. @@ -46,6 +45,7 @@ Bun-native TypeScript with no separate server compile step. gone, and on a new `src/` area nobody claimed. - `scripts/` — release and maintenance tooling; `scripts/release.ts` is the release authority. +- `desktop/` — Tauri v2 desktop shell, bootstrap UI, and compiled proxy sidecar preparation. - `devlog/` — planning and investigation notes, tracked in this repository. See "The `devlog` directory" below for what may and may not go there. diff --git a/README.md b/README.md index 88a6438b3dc..d5a2b010cf0 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,16 @@ Open **http://localhost:10100** and configure everything in the web dashboard (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. -### macOS menu bar app +### macOS desktop app and widget -A native companion for proxy status, usage, and provider quotas without opening the -dashboard. The source lives in [`app/`](./app) (Swift + AppKit, no third-party -dependencies). Download it from the +Download the desktop app for macOS, Windows, or Linux from the +[latest releases](https://github.com/lidge-jun/opencodex/releases). + +A native desktop app and WidgetKit extension for proxy status, usage, and provider +quotas without opening the dashboard. The snapshot model lives in [`app/`](./app) +(`MenuBarCore`). Download it from the [releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with -`bun run build:macos`. +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. The first launch needs a right-click → Open, because the app is ad-hoc signed rather than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) diff --git a/app/Info.plist b/app/Info.plist deleted file mode 100644 index 3d52873063f..00000000000 --- a/app/Info.plist +++ /dev/null @@ -1,42 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - OpenCodexMenuBar - CFBundleIdentifier - com.opencodex.menubar - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - OpenCodex - CFBundleDisplayName - OpenCodex - CFBundlePackageType - APPL - CFBundleIconFile - OpenCodex - CFBundleShortVersionString - 0.0.0 - CFBundleVersion - 0.0.0 - LSUIElement - - LSMinimumSystemVersion - 13.0 - - NSAppTransportSecurity - - NSAllowsLocalNetworking - - - NSHumanReadableCopyright - MIT — opencodex contributors - - diff --git a/app/Package.swift b/app/Package.swift index 9e5f1372751..0e14e3bd5b8 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -2,26 +2,14 @@ import PackageDescription let package = Package( - name: "OpenCodexMenuBar", + name: "OpenCodexWidget", platforms: [.macOS(.v13)], products: [ - .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), - .executable(name: "MenuBarUITests", targets: ["MenuBarUITests"]), - .executable(name: "UIProbe", targets: ["UIProbe"]), - .executable(name: "IconProbe", targets: ["IconProbe"]), ], targets: [ .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), - // AppKit views live in a library so both the app and the visual-QA probe can - // build the same surface. An executable target cannot be imported. - .target(name: "MenuBarUI", dependencies: ["MenuBarCore"], path: "Sources/MenuBarUI"), - .executableTarget( - name: "MenuBarApp", - dependencies: ["MenuBarCore", "MenuBarUI"], - path: "Sources/MenuBarApp" - ), .executableTarget( name: "OpenCodexWidget", dependencies: ["MenuBarCore"], @@ -41,15 +29,6 @@ let package = Package( dependencies: ["MenuBarCore"], path: "Sources/MenuBarCoreTests" ), - // UI-layer tests need AppKit and an NSApplication, so they are a separate - // executable from the dependency-free core suite. - .executableTarget( - name: "MenuBarUITests", - dependencies: ["MenuBarCore", "MenuBarUI"], - path: "Sources/MenuBarUITests" - ), - .executableTarget(name: "UIProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/UIProbe"), - .executableTarget(name: "IconProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/IconProbe"), ], swiftLanguageVersions: [.v5] ) diff --git a/app/Sources/IconProbe/main.swift b/app/Sources/IconProbe/main.swift deleted file mode 100644 index f9a359114d5..00000000000 --- a/app/Sources/IconProbe/main.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Renders every menu bar glyph state to one sheet so the state signal can be verified -// visually. The notch previously did not render at all, which made protected and -// at-risk indistinguishable. -import AppKit -import MenuBarCore -import MenuBarUI - -let states: [(String, ProxyState)] = [ - ("protected", .running(StartupHealth(status: "protected"))), - ("at-risk", .running(StartupHealth(status: "at-risk"))), - ("loading", .loading), - ("stopped", .unreachable), -] - -let scale: CGFloat = 6 -let cell = NSSize(width: 17 * scale, height: 17 * scale) -let sheet = NSImage(size: NSSize(width: cell.width * CGFloat(states.count), height: cell.height)) -sheet.lockFocus() -NSColor.white.setFill() -NSRect(origin: .zero, size: sheet.size).fill() -for (i, entry) in states.enumerated() { - let img = StatusIcon.image(for: entry.1) - let rect = NSRect(x: CGFloat(i) * cell.width, y: 0, width: cell.width, height: cell.height) - NSGraphicsContext.current?.imageInterpolation = .none - img.draw(in: rect.insetBy(dx: 8, dy: 8)) -} -sheet.unlockFocus() -if let tiff = sheet.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), - let png = rep.representation(using: .png, properties: [:]) { - try? png.write(to: URL(fileURLWithPath: "/tmp/glyphs.png")) -} -print("wrote /tmp/glyphs.png:", states.map(\.0).joined(separator: ", ")) diff --git a/app/Sources/MenuBarApp/main.swift b/app/Sources/MenuBarApp/main.swift deleted file mode 100644 index 710ac8c51b9..00000000000 --- a/app/Sources/MenuBarApp/main.swift +++ /dev/null @@ -1,11 +0,0 @@ -import AppKit -import MenuBarUI - -let app = NSApplication.shared -// .accessory keeps it out of the Dock; LSUIElement in Info.plist does the same for the -// packaged bundle, and this covers `swift run` during development. -app.setActivationPolicy(.accessory) - -let delegate = AppDelegate() -app.delegate = delegate -app.run() diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift index 412a5ff5c0b..23b34e1d0c3 100644 --- a/app/Sources/MenuBarCore/Discovery.swift +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -57,7 +57,7 @@ public enum ProxyDiscovery { /// Reads `runtime-port.json`, falling back to the default port on any problem. /// /// Every failure mode — missing file, malformed JSON, out-of-range port — resolves to - /// the default rather than throwing. A menu bar app that refuses to start because a + /// the default rather than throwing. A desktop app that refuses to start because a /// cache file is unreadable would be worse than one that probes the usual port. public static func resolve(configDirectory directory: URL) -> ProxyEndpoint { let file = directory.appendingPathComponent("runtime-port.json") diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 4125657a62b..5154fd7e835 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -2,7 +2,7 @@ import Foundation /// Owns the refresh schedule and turns transport results into a `ProxySnapshot`. /// -/// Polling is deliberately conservative. A menu bar app that hits a local server every +/// Polling is deliberately conservative. A desktop app that hits a local server every /// five seconds forever is a battery complaint waiting to happen, so heavy aggregation /// endpoints are fetched only while the popover is open, and repeated failures back the /// liveness tick off rather than hammering a proxy the user has stopped on purpose. diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index 53188278140..e9a31984244 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -275,7 +275,7 @@ public actor ProxyClient { request.httpMethod = method request.timeoutInterval = timeout ?? (method == "GET" ? 4 : 6) let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" - request.setValue("OpenCodexMenuBar/\(version)", forHTTPHeaderField: "User-Agent") + request.setValue("OpenCodexWidget/\(version)", forHTTPHeaderField: "User-Agent") if let credential = key ?? apiKey { request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") } diff --git a/app/Sources/MenuBarCore/WidgetSnapshot.swift b/app/Sources/MenuBarCore/WidgetSnapshot.swift index 3dc4f9cd989..6ea48c89783 100644 --- a/app/Sources/MenuBarCore/WidgetSnapshot.swift +++ b/app/Sources/MenuBarCore/WidgetSnapshot.swift @@ -127,7 +127,7 @@ public final class WidgetSnapshotStore: @unchecked Sendable { private var loggedFailures = Set() public init( - widgetBundleID: String = "com.opencodex.menubar.widget", + widgetBundleID: String = "com.opencodex.desktop.widget", fileManager: FileManager = .default, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser ) { diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index 570e10bdeab..d46a667e0fe 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -283,7 +283,7 @@ enum TransportSuite { let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" t.expect(url.contains("range=7d"), "expected range=7d in \(url)") t.expect(url.contains("/api/usage"), "expected /api/usage in \(url)") - t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexMenuBar/dev") + t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexWidget/dev") } t.test("requests: the provider patch sends exactly {\"disabled\":true}") { diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift deleted file mode 100644 index 7ca5c1d8e29..00000000000 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ /dev/null @@ -1,254 +0,0 @@ -import AppKit -import MenuBarCore - -public final class AppDelegate: NSObject, NSApplicationDelegate { - private var statusItem: NSStatusItem? - /// A key-capable panel rather than `NSPopover`. - /// - /// This is the single most-tested decision in this file. `NSPopover` from an - /// accessory (`LSUIElement`) process creates a window that never appears in - /// `NSApp.windows` and reports `canBecomeKey == false`, so macOS will not route key - /// events to it no matter how the process is activated — Escape and the Tab path - /// simply never arrive. A `nonactivatingPanel` that overrides `canBecomeKey` - /// measures as `canBecomeKey=1 isKey=1` under the same conditions. - private let panel = PopoverPanel() - private let controller = PopoverViewController() - private var coordinator: PollingCoordinator? - private var actions: ActionCoordinator? - private var client: ProxyClient? - private let widgetStore = WidgetSnapshotStore() - /// The snapshot the UI is currently showing, for decisions that need context - /// (the start command to display, the default provider to protect). - private var latest: ProxySnapshot? - private var endpoint = ProxyEndpoint.default - private var pollTask: Task? - /// Fallback Escape handling for the case where the panel is visible but another - /// process holds focus. Installed on open, removed on close. - private var escapeMonitor: Any? - - public override init() { super.init() } - - public func applicationDidFinishLaunching(_ notification: Notification) { - endpoint = ProxyDiscovery.resolve() - let client = ProxyClient(endpoint: endpoint) - self.client = client - let coordinator = PollingCoordinator(client: client, endpoint: endpoint) - self.coordinator = coordinator - self.actions = ActionCoordinator(client: client) - - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button?.image = StatusIcon.image(for: .loading) - item.button?.imagePosition = .imageOnly - item.button?.target = self - item.button?.action = #selector(togglePopover) - item.button?.setAccessibilityLabel("OpenCodex proxy status") - statusItem = item - - controller.onDashboard = { [weak self] in self?.openDashboard() } - controller.onCompanionSettings = { [weak self] in self?.openCompanionSettings() } - controller.onStop = { [weak self] in self?.stopProxy() } - controller.onRefresh = { [weak self] in self?.refreshNow() } - controller.onAddKey = { [weak self] in self?.openDashboard() } - controller.onRetry = { [weak self] in self?.refreshNow() } - controller.onToggleProvider = { [weak self] name, disable in - self?.toggleProvider(name, disable: disable) - } - controller.onQuit = { NSApp.terminate(nil) } - - panel.contentViewController = controller - panel.onDismiss = { [weak self] in self?.handlePanelClosed() } - - // The observer closure is `@Sendable` and crosses actor boundaries, so it must - // not capture the delegate. It hops to the main actor and looks the delegate up - // there instead. - Task { - await coordinator.observe { snapshot in - Task { @MainActor in - (NSApp.delegate as? AppDelegate)?.render(snapshot) - } - } - await MainActor.run { (NSApp.delegate as? AppDelegate)?.startPolling() } - } - } - - public func applicationWillTerminate(_ notification: Notification) { - pollTask?.cancel() - removeEscapeMonitor() - panel.dismiss() - } - - // MARK: - Polling - - @MainActor - fileprivate func startPolling() { - guard let coordinator else { return } - pollTask?.cancel() - pollTask = Task { - while !Task.isCancelled { - await coordinator.refresh() - let interval = await coordinator.currentInterval - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - } - } - - private func refreshNow() { - Task { [coordinator] in await coordinator?.refresh(includeHeavy: true) } - } - - @MainActor - fileprivate func render(_ snapshot: ProxySnapshot) { - latest = snapshot - let title = snapshot.menuBarTitle ?? "" - statusItem?.button?.title = title - statusItem?.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium) - statusItem?.button?.imagePosition = title.isEmpty ? .imageOnly : .imageLeading - statusItem?.button?.image = StatusIcon.image(for: snapshot.state) - statusItem?.button?.toolTip = "OpenCodex — \(snapshot.state.title) (\(snapshot.endpoint.display))" - controller.apply(snapshot) - let widgetSnapshot = WidgetSnapshot.make(from: snapshot) - Task.detached { [widgetStore] in widgetStore.writeIfChanged(widgetSnapshot) } - } - - // MARK: - Actions - - #if DEBUG - /// Testing hook: drives the exact presentation path a status-item click uses, so a - /// harness can verify key focus and Escape without Accessibility permission. - /// Debug-only — it is not part of the shipped surface. - public func debugTogglePanel() { togglePopover() } - #endif - - @objc private func togglePopover() { - guard let button = statusItem?.button else { return } - if panel.isShown { - panel.dismiss() - } else { - panel.present(from: button) - installEscapeMonitor() - Task { [coordinator] in await coordinator?.setPopoverOpen(true) } - } - } - - /// Called by the panel whenever it closes, however it was dismissed. - private func handlePanelClosed() { - removeEscapeMonitor() - Task { [coordinator] in await coordinator?.setPopoverOpen(false) } - } - - /// The panel is key-capable, so `cancelOperation(_:)` handles Escape in the normal - /// case. This local monitor is belt-and-braces for the window where the panel is up - /// but focus sits elsewhere in this process, such as the confirmation sheet. - private func installEscapeMonitor() { - removeEscapeMonitor() - escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - // While a confirmation is up, Escape belongs to the alert: consuming it - // here dismissed the panel and stranded the alert with no way to cancel. - guard event.keyCode == 53, - self?.panel.isShown == true, - self?.panel.isPresentingModal == false - else { return event } - self?.panel.dismiss() - return nil - } - } - - - private func removeEscapeMonitor() { - if let monitor = escapeMonitor { NSEvent.removeMonitor(monitor) } - escapeMonitor = nil - } - - private func openDashboard() { - NSWorkspace.shared.open(endpoint.baseURL) - } - - private func openCompanionSettings() { - guard let url = URL(string: "\(endpoint.baseURL.absoluteString)/#/usage#usage-section-companion") else { return } - NSWorkspace.shared.open(url) - } - - /// Stopping is destructive: it interrupts in-flight requests and stops the launchd - /// service, so nothing restarts the proxy. It always confirms first. - private func stopProxy() { - let alert = NSAlert() - alert.messageText = "Stop the OpenCodex proxy?" - alert.informativeText = - "In-flight requests will be interrupted, and OpenCodex will not restart on its own." - alert.alertStyle = .warning - alert.addButton(withTitle: "Stop proxy") - alert.addButton(withTitle: "Cancel") - - // The alert takes key focus, which would otherwise trip resignKey and dismiss - // the panel behind it — leaving a user who chose Cancel with nothing. - panel.isPresentingModal = true - NSApp.activate(ignoringOtherApps: true) - let confirmed = alert.runModal() == .alertFirstButtonReturn - panel.isPresentingModal = false - - guard confirmed else { - panel.makeKeyAndOrderFront(nil) - return - } - - let startCommand = latest?.lastKnownStartCommand ?? "ocx start" - controller.showResult("Stopping…", isError: false) - - Task { [actions, coordinator] in - let outcome = await actions?.stop(startCommand: startCommand) ?? .failed("Unavailable.") - await coordinator?.refresh() - await MainActor.run { [weak self] in - switch outcome { - case .succeeded: - self?.controller.showResult("Proxy stopped.", isError: false) - case .requiresManualStart(let command): - // Not a failure — the API has no start endpoint by design. - self?.controller.showResult("Proxy stopped. Start it again with \(command)", isError: false) - case .stoppedWithRestoreFailure(let command): - // The proxy is down but native Codex still points at the dead port. - self?.controller.showResult( - "Proxy stopped, but restoring native Codex failed. Run `ocx restore`, then \(command)", - isError: true - ) - case .failed(let message): - self?.controller.showResult(message, isError: true) - } - } - } - } - - /// Optimistic toggle: the switch has already moved, so a rejection must move it back - /// rather than leave the UI showing a state the proxy refused. - private func toggleProvider(_ name: String, disable: Bool) { - let defaultProvider = latest?.defaultProvider - controller.setProviderBusy(name, true, intended: !disable) - - Task { [actions, coordinator] in - let outcome = await actions?.setProvider(name, disabled: disable, defaultProvider: defaultProvider) - ?? .failed("Unavailable.") - await MainActor.run { [weak self] in - switch outcome { - case .succeeded: - self?.controller.showResult( - disable ? "\(name) disabled." : "\(name) enabled.", - isError: false - ) - case .failed(let message): - self?.controller.revertProvider(name, to: !disable) - self?.controller.showResult(message, isError: true) - case .requiresManualStart, .stoppedWithRestoreFailure: - // Not reachable for a provider write. - break - } - } - // Re-read so the summary line and switch states match the proxy, not our - // optimistic guess. refreshAndWait rather than refresh: a coalesced refresh - // returns immediately, which would re-enable the switch against pre-write - // data. - await coordinator?.refreshAndWait() - await MainActor.run { [weak self] in - self?.controller.setProviderBusy(name, false) - } - } - } -} diff --git a/app/Sources/MenuBarUI/CompanionViews.swift b/app/Sources/MenuBarUI/CompanionViews.swift deleted file mode 100644 index a23c4bf42c9..00000000000 --- a/app/Sources/MenuBarUI/CompanionViews.swift +++ /dev/null @@ -1,78 +0,0 @@ -import AppKit -import MenuBarCore - -final class ModelsListView: NSView { - private let stack = NSStackView() - private let caption = makeLabel("MODELS", font: Theme.micro, color: Theme.faint) - - init() { - super.init(frame: .zero) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - stack.addArrangedSubview(caption) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - clearRows() - let rows = snapshot.todayRows.sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) }.prefix(5) - isHidden = !snapshot.settings.showModels || rows.isEmpty - for row in rows { - let model = [row.provider, row.model].compactMap { $0 }.joined(separator: "/") - let cost = snapshot.settings.showCost ? " · \(Format.cost(row.estimatedCostUsd))" : "" - stack.addArrangedSubview(makeLabel( - "\(model) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))\(cost)", - font: Theme.caption, color: Theme.text - )) - } - } - - private func clearRows() { - for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } - } -} - -final class AccountsListView: NSView { - private let stack = NSStackView() - private let caption = makeLabel("ACCOUNTS", font: Theme.micro, color: Theme.faint) - - init() { - super.init(frame: .zero) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - stack.addArrangedSubview(caption) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - clearRows() - let rows = (snapshot.today?.accounts ?? []).sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) } - isHidden = !snapshot.settings.showAccounts || rows.isEmpty - for row in rows { - stack.addArrangedSubview(makeLabel( - "\(row.accountLogLabel ?? Format.unknown) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))", - font: Theme.caption, color: Theme.text - )) - } - } - - private func clearRows() { - for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } - } -} diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift deleted file mode 100644 index 5c579f88be9..00000000000 --- a/app/Sources/MenuBarUI/PopoverPanel.swift +++ /dev/null @@ -1,170 +0,0 @@ -import AppKit - -/// The popover surface. -/// -/// Deliberately a panel rather than `NSPopover`. Measured on macOS 27 from an accessory -/// (`LSUIElement`) process: the window `NSPopover` creates never appears in -/// `NSApp.windows` and reports `canBecomeKey == false`, so the OS refuses to route key -/// events to it — Escape and Tab never arrive regardless of how the process is -/// activated. The same probe against this panel reports `canBecomeKey=1 isKey=1`. -/// -/// `nonactivatingPanel` keeps the click-through feel of a menu bar popover: opening it -/// does not steal focus from the user's editor. -public final class PopoverPanel: NSPanel { - /// Invoked whenever the panel closes, however it was dismissed. - public var onDismiss: (() -> Void)? - - private var clickOutsideMonitor: Any? - - public init() { - super.init( - contentRect: NSRect(x: 0, y: 0, width: 340, height: 300), - styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], - backing: .buffered, - defer: false - ) - isFloatingPanel = true - level = .statusBar - hidesOnDeactivate = false - becomesKeyOnlyIfNeeded = false - isOpaque = false - backgroundColor = .clear - hasShadow = true - isMovable = false - animationBehavior = .utilityWindow - } - - /// Wraps the content in a real popover material. - /// - /// A borderless panel has NO background of its own: without this the dashboard - /// composites straight onto whatever application is underneath, so labels collide - /// with the app behind it and contrast depends on that app's colours. `NSPopover` - /// supplies this surface automatically; a panel must build it. - public override var contentViewController: NSViewController? { - didSet { - guard let content = contentViewController?.view else { return } - contentView = PopoverSurface.make(content: content) - } - } - - /// Suspends resign-key dismissal, so presenting a modal sheet does not tear the - /// panel down behind it and strand a user who chose Cancel. - public var isPresentingModal = false - - public override var canBecomeKey: Bool { true } - /// Never main: this is chrome, not a document window. - public override var canBecomeMain: Bool { false } - - public var isShown: Bool { isVisible } - - /// Presents under a status item button, clamped to the visible screen. - public func present(from button: NSStatusBarButton) { - guard let buttonWindow = button.window else { return } - layoutContent() - - let size = contentViewController?.preferredContentSize ?? frame.size - setContentSize(size) - - let buttonRect = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil)) - var origin = NSPoint( - x: buttonRect.midX - size.width / 2, - y: buttonRect.minY - size.height - 6 - ) - - if let screen = buttonWindow.screen ?? NSScreen.main { - let visible = screen.visibleFrame - origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - size.width - 8) - origin.y = max(origin.y, visible.minY + 8) - } - - setFrameOrigin(origin) - makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - installClickOutsideMonitor() - } - - public func dismiss() { - // Idempotent: a late monitor callback must not re-run teardown. - guard isVisible else { return } - removeClickOutsideMonitor() - orderOut(nil) - onDismiss?() - } - - /// Transient behaviour: clicking anywhere else dismisses, matching what a menu bar - /// popover trained the user to expect. - private func installClickOutsideMonitor() { - removeClickOutsideMonitor() - clickOutsideMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] _ in - self?.dismiss() - } - } - - private func removeClickOutsideMonitor() { - if let monitor = clickOutsideMonitor { NSEvent.removeMonitor(monitor) } - clickOutsideMonitor = nil - } - - public override func cancelOperation(_ sender: Any?) { dismiss() } - - public override func resignKey() { - super.resignKey() - // Losing key focus means the user moved on — unless we put the focus elsewhere - // ourselves by presenting a confirmation. - guard !isPresentingModal else { return } - if isVisible { dismiss() } - } - - private func layoutContent() { - contentViewController?.view.layoutSubtreeIfNeeded() - } -} - -private enum PopoverSurface { - static func make(content: NSView) -> NSView { - let surface: NSView -#if compiler(>=6.2) - if #available(macOS 26, *) { - let glass = NSGlassEffectView() - glass.cornerRadius = 16 - glass.style = .regular - glass.contentView = content - surface = glass - } else { - surface = makeMaterialSurface(content: content) - } -#else - surface = makeMaterialSurface(content: content) -#endif - - let host = NSView() - host.addSubview(surface) - surface.translatesAutoresizingMaskIntoConstraints = false - content.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - surface.topAnchor.constraint(equalTo: host.topAnchor), - surface.leadingAnchor.constraint(equalTo: host.leadingAnchor), - surface.trailingAnchor.constraint(equalTo: host.trailingAnchor), - surface.bottomAnchor.constraint(equalTo: host.bottomAnchor), - content.topAnchor.constraint(equalTo: surface.topAnchor), - content.leadingAnchor.constraint(equalTo: surface.leadingAnchor), - content.trailingAnchor.constraint(equalTo: surface.trailingAnchor), - content.bottomAnchor.constraint(equalTo: surface.bottomAnchor), - ]) - return host - } - - private static func makeMaterialSurface(content: NSView) -> NSView { - let effect = NSVisualEffectView() - effect.material = .popover - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = 10 - effect.layer?.masksToBounds = true - effect.addSubview(content) - return effect - } -} diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift deleted file mode 100644 index 1cc8dd51ae4..00000000000 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ /dev/null @@ -1,384 +0,0 @@ -import AppKit -import MenuBarCore - -/// The popover body: one column ordered by urgency. -/// -/// Deliberately not a tab bar. A menu bar popover is a glance surface, and tabs would put -/// the answer to "is it fine?" one click away three times out of four. -/// -/// Fixed header and action row with a scrolling middle: the quota and provider sections -/// grow with the user's configuration, and an uncapped popover would eventually run off -/// the screen. -public final class PopoverViewController: NSViewController { - public override init(nibName: NSNib.Name?, bundle: Bundle?) { - super.init(nibName: nibName, bundle: bundle) - } - - public required init?(coder: NSCoder) { nil } - - /// The popover never grows past this; the variable middle scrolls instead. - private static let maxHeight: CGFloat = 480 - - // Fixed chrome - private let header = StatusHeaderView() - private let dashboardButton = NSButton() - private let stopButton = NSButton() - private let overflowButton = NSButton() - /// State-specific call to action: "Add key…" or "Retry". - private let primaryButton = NSButton() - - // Scrolling body - private let scrollView = NSScrollView() - private let body = NSStackView() - private let metrics = MetricsView() - private let timelineChart = TimelineChartView() - private let models = ModelsListView() - private let accounts = AccountsListView() - private let quotaStack = NSStackView() - private let quotaEmpty = makeLabel("No provider quota sources connected.", font: Theme.caption, color: Theme.muted) - private let providers = ProviderListView() - /// Transient result of the last write action. Actions that report nothing leave the - /// user guessing whether anything happened. - private let resultBanner = makeLabel("", font: Theme.caption, color: Theme.muted) - private let skeleton = SkeletonView() - private let guidanceLabel: NSTextField = { - let field = makeLabel("", font: Theme.caption, color: Theme.muted) - // Guidance is a sentence, not a stat: let it wrap instead of truncating away - // the half that explains what to do. - field.lineBreakMode = .byWordWrapping - field.maximumNumberOfLines = 3 - field.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - return field - }() - private let commandField = NSTextField(labelWithString: "") - private let metricsSeparator = makeSeparator() - private let quotaSeparator = makeSeparator() - - public var onDashboard: (() -> Void)? - public var onCompanionSettings: (() -> Void)? - public var onStop: (() -> Void)? - public var onQuit: (() -> Void)? - public var onRefresh: (() -> Void)? - /// `(provider, shouldDisable)`. - public var onToggleProvider: ((String, Bool) -> Void)? - /// Distinct callbacks: "Retry" must retry in place, while "Add key…" navigates to - /// the dashboard. Routing both through one handler made Retry open a browser. - public var onAddKey: (() -> Void)? - public var onRetry: (() -> Void)? - - private var snapshot: ProxySnapshot? - private var scrollHeight: NSLayoutConstraint? - /// Guards the banner's auto-hide so a newer result is not cleared by an older timer. - private var resultToken = 0 - - public override func loadView() { - configureControls() - resultBanner.isHidden = true - resultBanner.lineBreakMode = .byWordWrapping - resultBanner.maximumNumberOfLines = 3 - resultBanner.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - providers.onToggle = { [weak self] name, disable in - self?.onToggleProvider?(name, disable) - } - - body.orientation = .vertical - body.alignment = .leading - body.spacing = Theme.rowGap - body.setViews( - [skeleton, metrics, timelineChart, metricsSeparator, models, quotaStack, quotaEmpty, - accounts, providers, quotaSeparator, resultBanner, guidanceLabel, commandField], - in: .top - ) - body.translatesAutoresizingMaskIntoConstraints = false - - // A flipped clip view puts the scroll origin at the TOP. Without this, content - // that overflows opens scrolled to the bottom, hiding the status and metrics the - // urgency order exists to surface first. - scrollView.contentView = FlippedClipView() - scrollView.documentView = body - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.translatesAutoresizingMaskIntoConstraints = false - - let actions = NSStackView(views: [dashboardButton, stopButton, primaryButton, NSView(), overflowButton]) - actions.orientation = .horizontal - actions.spacing = Theme.rowGap - actions.alignment = .centerY - - let column = NSStackView(views: [header, makeSeparator(), scrollView, actions]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = Theme.rowGap - column.edgeInsets = NSEdgeInsets( - top: Theme.gutter, left: Theme.gutter, - bottom: Theme.gutter, right: Theme.gutter - ) - column.translatesAutoresizingMaskIntoConstraints = false - - let root = NSView(frame: NSRect(x: 0, y: 0, width: Theme.width, height: 300)) - root.addSubview(column) - - let contentWidth = Theme.width - Theme.gutter * 2 - NSLayoutConstraint.activate([ - column.topAnchor.constraint(equalTo: root.topAnchor), - column.leadingAnchor.constraint(equalTo: root.leadingAnchor), - column.trailingAnchor.constraint(equalTo: root.trailingAnchor), - column.bottomAnchor.constraint(equalTo: root.bottomAnchor), - root.widthAnchor.constraint(equalToConstant: Theme.width), - header.widthAnchor.constraint(equalToConstant: contentWidth), - actions.widthAnchor.constraint(equalToConstant: contentWidth), - scrollView.widthAnchor.constraint(equalToConstant: contentWidth), - body.widthAnchor.constraint(equalToConstant: contentWidth), - ]) - - let heightConstraint = scrollView.heightAnchor.constraint(equalToConstant: 120) - heightConstraint.isActive = true - scrollHeight = heightConstraint - - view = root - } - - private func configureControls() { - for (button, title) in [(dashboardButton, "Dashboard"), (stopButton, "Stop proxy")] { - button.title = title - button.bezelStyle = .rounded - button.controlSize = .small - button.font = Theme.caption - button.target = self - } - dashboardButton.action = #selector(dashboardTapped) - stopButton.action = #selector(stopTapped) - - primaryButton.bezelStyle = .rounded - primaryButton.controlSize = .small - primaryButton.font = Theme.caption - primaryButton.target = self - primaryButton.action = #selector(primaryTapped) - primaryButton.isHidden = true - - overflowButton.title = "···" - overflowButton.bezelStyle = .rounded - overflowButton.controlSize = .small - overflowButton.font = Theme.caption - overflowButton.target = self - overflowButton.action = #selector(overflowTapped) - overflowButton.setAccessibilityLabel("More actions") - - quotaStack.orientation = .vertical - quotaStack.alignment = .leading - quotaStack.spacing = Theme.tightGap - - commandField.font = Theme.numericSmall - commandField.textColor = Theme.text - commandField.isSelectable = true - commandField.isBordered = false - commandField.drawsBackground = false - } - - public func apply(_ snapshot: ProxySnapshot) { - self.snapshot = snapshot - header.apply(snapshot) - - let showsData = snapshot.showsData - let isLoading = !snapshot.hasEverLoaded && snapshot.state == .loading - - // Loading shows structure, not empty copy: the shape of the answer is already - // known, only the values are missing. - skeleton.isHidden = !isLoading - - metrics.isHidden = !showsData - metricsSeparator.isHidden = !showsData - quotaSeparator.isHidden = !showsData - if showsData { - metrics.apply(snapshot) - timelineChart.apply(snapshot) - models.apply(snapshot) - accounts.apply(snapshot) - applyQuotas(snapshot) - providers.apply(snapshot) - } else { - timelineChart.isHidden = true - models.isHidden = true - accounts.isHidden = true - quotaStack.isHidden = true - quotaEmpty.isHidden = true - providers.isHidden = true - } - - applyGuidance(snapshot) - applyActions(snapshot, isLoading: isLoading) - resize() - } - - private func applyQuotas(_ snapshot: ProxySnapshot) { - for view in quotaStack.arrangedSubviews { - quotaStack.removeArrangedSubview(view) - view.removeFromSuperview() - } - let rows = snapshot.quotaRows - quotaStack.isHidden = rows.isEmpty - // "Not fetched yet" and "the proxy reported none" are different facts. - quotaEmpty.isHidden = !(rows.isEmpty && snapshot.quotasLoaded) - for quota in rows { - let row = QuotaRowView(quota: quota) - row.translatesAutoresizingMaskIntoConstraints = false - quotaStack.addArrangedSubview(row) - row.widthAnchor.constraint(equalTo: quotaStack.widthAnchor).isActive = true - } - } - - /// Shows the outcome of a write action, then clears itself. A banner that never - /// leaves would become permanent furniture. - public func showResult(_ text: String, isError: Bool) { - resultBanner.stringValue = text - resultBanner.textColor = isError ? Theme.red : Theme.muted - resultBanner.isHidden = false - refreshSize() - - resultToken &+= 1 - let token = resultToken - DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in - guard let self, self.resultToken == token else { return } - self.resultBanner.isHidden = true - self.refreshSize() - } - } - - public func revertProvider(_ name: String, to enabled: Bool) { - providers.revert(name, to: enabled) - } - - public func setProviderBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { - providers.setBusy(name, busy, intended: intended) - } - - /// Re-measures after content changes height (disclosure, banner). - public func refreshSize() { resize() } - - /// Guidance text plus any command the user should run. Commands are shown as - /// selectable text; the app never executes them. - private func applyGuidance(_ snapshot: ProxySnapshot) { - var guidance: String? - var command: String? - - switch snapshot.nextAction { - case .none: - // A running-but-at-risk proxy still has advice worth surfacing. - if case .running = snapshot.state, let recommended = snapshot.recommendedCommand { - guidance = "Recommended:" - command = recommended - } - case .runCommand(let value): - guidance = "Start it again with:" - command = value - case .addAPIKey: - guidance = "This proxy is bound to a non-loopback address and needs a key." - case .retry: - guidance = snapshot.dataAge.map { "Showing data from \(Format.age($0)). Retrying automatically." } - ?? "Retrying automatically." - } - - guidanceLabel.isHidden = guidance == nil - guidanceLabel.stringValue = guidance ?? "" - commandField.isHidden = command == nil - commandField.stringValue = command ?? "" - if let command { - commandField.setAccessibilityLabel("Command to run: \(command)") - } - } - - private func applyActions(_ snapshot: ProxySnapshot, isLoading: Bool) { - // Nothing is actionable before the first read completes. - dashboardButton.isEnabled = !isLoading - overflowButton.isEnabled = !isLoading - stopButton.isEnabled = snapshot.state.isRunning - stopButton.isHidden = !snapshot.state.isRunning - - switch snapshot.nextAction { - case .addAPIKey: - primaryButton.isHidden = false - primaryButton.title = "Add key…" - primaryButton.keyEquivalent = "\r" - case .retry: - primaryButton.isHidden = false - primaryButton.title = "Retry" - primaryButton.keyEquivalent = "\r" - case .none, .runCommand: - primaryButton.isHidden = true - primaryButton.keyEquivalent = "" - } - } - - private func resize() { - view.layoutSubtreeIfNeeded() - let bodyHeight = ceil(body.fittingSize.height) - // Chrome is the header, separator, action row, and insets. - let chrome = ceil(header.fittingSize.height) + Theme.gutter * 2 + Theme.rowGap * 3 + 28 - let natural = chrome + bodyHeight - let capped = min(Self.maxHeight, natural) - // Scrollers appear only when the content genuinely overflows; a scroll bar on a - // three-line loading state reads as a broken layout. - let overflowing = natural > Self.maxHeight - scrollView.hasVerticalScroller = overflowing - scrollHeight?.constant = max(0, capped - chrome) - preferredContentSize = NSSize(width: Theme.width, height: max(96, capped)) - } - - // MARK: - Actions - - @objc private func dashboardTapped() { onDashboard?() } - @objc private func stopTapped() { onStop?() } - @objc private func primaryTapped() { - switch snapshot?.nextAction { - case .addAPIKey: onAddKey?() - case .retry: onRetry?() - default: break - } - } - @objc private func refreshTapped() { onRefresh?() } - @objc private func quitTapped() { onQuit?() } - - @objc private func overflowTapped() { - let menu = NSMenu() - menu.addItem(withTitle: "Refresh", action: #selector(refreshTapped), keyEquivalent: "r").target = self - menu.addItem(withTitle: "Open dashboard", action: #selector(dashboardTapped), keyEquivalent: "").target = self - menu.addItem(withTitle: "Companion settings…", action: #selector(companionSettingsTapped), keyEquivalent: "").target = self - menu.addItem(.separator()) - menu.addItem(withTitle: "Quit OpenCodex", action: #selector(quitTapped), keyEquivalent: "q").target = self - menu.popUp(positioning: nil, at: NSPoint(x: 0, y: overflowButton.bounds.height + 4), in: overflowButton) - } - @objc private func companionSettingsTapped() { onCompanionSettings?() } - - /// AppKit routes Escape here for the whole responder chain, which `keyDown` does not - /// reliably receive inside a popover. - public override func cancelOperation(_ sender: Any?) { - view.window?.performClose(nil) - } -} - -/// Top-anchored clip view. AppKit scroll views are bottom-origin by default. -final class FlippedClipView: NSClipView { - override var isFlipped: Bool { true } -} - -/// Loading structure: grey bars where values will appear, so the first paint shows the -/// shape of the answer instead of empty space or a spinner. -final class SkeletonView: NSView { - override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: 84) - } - - override func draw(_ dirtyRect: NSRect) { - Theme.raised.setFill() - let widths: [CGFloat] = [72, 0, 96, 140, 120, 110] - var y = bounds.maxY - 12 - for width in widths { - guard width > 0 else { y -= 8; continue } - let rect = NSRect(x: 0, y: y, width: width, height: 9) - NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill() - y -= 15 - } - } -} diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift deleted file mode 100644 index c0d5b6dc0b2..00000000000 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ /dev/null @@ -1,255 +0,0 @@ -import AppKit -import MenuBarCore - -/// Collapsed provider list with per-provider enable/disable switches. -/// -/// Collapsed by default: reading status is frequent, toggling a provider is rare, and -/// the urgency order in `003` puts actions below information. -public final class ProviderListView: NSView { - private let disclosure = NSButton() - private let summary = makeLabel("", font: Theme.caption, color: Theme.muted) - private let rows = NSStackView() - private var expanded = false - private var snapshot: ProxySnapshot? - /// Providers with a write in flight, mapped to the state the USER chose. A poll can - /// still be carrying pre-write data, so the intended value — not the snapshot — is - /// what a rebuilt row must show. - private var pending: [String: Bool] = [:] - - /// `(provider, shouldDisable)`. - public var onToggle: ((String, Bool) -> Void)? - - public override init(frame: NSRect) { - super.init(frame: frame) - - disclosure.bezelStyle = .disclosure - disclosure.setButtonType(.onOff) - disclosure.title = "" - disclosure.target = self - disclosure.action = #selector(toggleExpanded) - disclosure.setAccessibilityLabel("Show providers") - - rows.orientation = .vertical - rows.alignment = .leading - rows.spacing = Theme.tightGap - rows.isHidden = true - - let header = NSStackView(views: [disclosure, summary]) - header.orientation = .horizontal - header.spacing = Theme.tightGap - header.alignment = .centerY - - let column = NSStackView(views: [header, rows]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = Theme.tightGap - column.translatesAutoresizingMaskIntoConstraints = false - addSubview(column) - NSLayoutConstraint.activate([ - column.topAnchor.constraint(equalTo: topAnchor), - column.leadingAnchor.constraint(equalTo: leadingAnchor), - column.trailingAnchor.constraint(equalTo: trailingAnchor), - column.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - public convenience init() { self.init(frame: .zero) } - - public required init?(coder: NSCoder) { nil } - - public func apply(_ snapshot: ProxySnapshot) { - self.snapshot = snapshot - - guard snapshot.providersLoaded else { - isHidden = true - return - } - isHidden = false - - if snapshot.providers.isEmpty { - summary.stringValue = "No providers configured." - disclosure.isHidden = true - rows.isHidden = true - return - } - - disclosure.isHidden = false - let enabled = snapshot.providers.filter(\.isEnabled).count - summary.stringValue = "\(enabled) of \(snapshot.providers.count) providers enabled" - rebuildRows(snapshot) - rows.isHidden = !expanded - } - - private func rebuildRows(_ snapshot: ProxySnapshot) { - for view in rows.arrangedSubviews { - rows.removeArrangedSubview(view) - view.removeFromSuperview() - } - - for provider in snapshot.visibleProviders.sorted(by: { $0.name < $1.name }) { - let isDefault = provider.name == snapshot.defaultProvider - let row = ProviderRowView( - provider: provider, - isDefault: isDefault - ) { [weak self] shouldDisable in - self?.onToggle?(provider.name, shouldDisable) - } - // A refresh that lands mid-write must not undo the optimistic state: apply - // the intended value first, then mark the row busy. - if let intended = pending[provider.name] { - row.setEnabled(intended) - row.setBusy(true) - } - row.translatesAutoresizingMaskIntoConstraints = false - rows.addArrangedSubview(row) - row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true - } - } - - /// Shared by the disclosure button and the test hook. - func setExpanded(_ value: Bool) { - expanded = value - disclosure.state = value ? .on : .off - rows.isHidden = !expanded - disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") - (window?.contentViewController as? PopoverViewController)?.refreshSize() - } - - var providerRows: [NSView] { rows.arrangedSubviews } - - @objc private func toggleExpanded() { - expanded = disclosure.state == .on - rows.isHidden = !expanded - disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") - // The popover has to grow or shrink with the disclosure. - (window?.contentViewController as? PopoverViewController)?.refreshSize() - } - - /// Reverts a switch after the proxy rejected the change. - public func revert(_ name: String, to enabled: Bool) { - pending[name] = nil - for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { - row.setEnabled(enabled) - row.setBusy(false) - } - } - - /// Marks a provider as having a write in flight. Its switch stays inert until the - /// authoritative refresh lands, so a poll cannot resurrect the pre-toggle state and - /// a second click cannot race the first. - /// `intended` is the state the user selected, retained so a poll landing mid-write - /// cannot snap the switch back. - public func setBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { - if busy { - pending[name] = intended ?? pending[name] ?? true - } else { - pending[name] = nil - } - for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { - if busy, let value = pending[name] { row.setEnabled(value) } - row.setBusy(busy) - } - } -} - -public final class ProviderRowView: NSView { - public let providerName: String - private let toggle = NSSwitch() - private let onToggle: (Bool) -> Void - private var baseEnabled = true - private var isBusy = false - - init(provider: ProviderSummary, isDefault: Bool, onToggle: @escaping (Bool) -> Void) { - self.providerName = provider.name - self.onToggle = onToggle - super.init(frame: .zero) - - let name = makeLabel(provider.name, font: Theme.caption, color: Theme.text) - let detail = makeLabel( - isDefault ? "default" : (provider.authMode ?? ""), - font: Theme.micro, - color: Theme.faint - ) - - let labels = NSStackView(views: [name, detail]) - labels.orientation = .vertical - labels.alignment = .leading - labels.spacing = 0 - - toggle.state = provider.isEnabled ? .on : .off - toggle.controlSize = .mini - toggle.target = self - toggle.action = #selector(switched) - - // The proxy rejects only DISABLING the default provider (`provider-routes.ts:178` - // guards on `rawBody.disabled && name === defaultProvider`). Enabling it is - // valid, so a default provider that is currently off must stay toggleable — - // otherwise the app strands the user in a state it cannot leave. - let wouldDisableDefault = isDefault && provider.isEnabled - toggle.isEnabled = !wouldDisableDefault - toggle.toolTip = wouldDisableDefault - ? "This is the default provider. Choose another default in the dashboard first." - : nil - baseEnabled = toggle.isEnabled - toggle.setAccessibilityLabel("\(provider.name) enabled") - - let row = NSStackView(views: [labels, NSView(), toggle]) - row.orientation = .horizontal - row.spacing = Theme.rowGap - row.alignment = .centerY - row.translatesAutoresizingMaskIntoConstraints = false - addSubview(row) - NSLayoutConstraint.activate([ - row.topAnchor.constraint(equalTo: topAnchor), - row.leadingAnchor.constraint(equalTo: leadingAnchor), - row.trailingAnchor.constraint(equalTo: trailingAnchor), - row.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func setEnabled(_ enabled: Bool) { toggle.state = enabled ? .on : .off } - - var toggleState: Bool { toggle.state == .on } - var toggleIsEnabled: Bool { toggle.isEnabled } - - /// Inert while its write is in flight, so a second click cannot race the first. - func setBusy(_ busy: Bool) { - isBusy = busy - toggle.isEnabled = busy ? false : baseEnabled - alphaValue = busy ? 0.6 : 1 - } - - @objc private func switched() { - // Optimistic: the switch has already moved. The caller reverts on failure. - onToggle(toggle.state == .off) - } -} - - -// MARK: - Test inspection - -/// Read-only hooks so the UI suite can assert on rendered control state rather than on -/// the view's private bookkeeping. -package extension ProviderListView { - /// Expands the list without going through a click, so tests do not depend on - /// NSButton action dispatch. - func expandForTesting() { setExpanded(true) } - - func isToggleOn(_ name: String) -> Bool? { row(name)?.isOn } - func isToggleEnabled(_ name: String) -> Bool? { row(name)?.isToggleEnabled } - func hasProviderForTesting(_ name: String) -> Bool { row(name) != nil } - - private func row(_ name: String) -> ProviderRowView? { - for case let row as ProviderRowView in providerRows where row.providerName == name { - return row - } - return nil - } -} - -package extension ProviderRowView { - var isOn: Bool { toggleState } - var isToggleEnabled: Bool { toggleIsEnabled } -} diff --git a/app/Sources/MenuBarUI/StatusIcon.swift b/app/Sources/MenuBarUI/StatusIcon.swift deleted file mode 100644 index e7eb2951b17..00000000000 --- a/app/Sources/MenuBarUI/StatusIcon.swift +++ /dev/null @@ -1,73 +0,0 @@ -import AppKit -import MenuBarCore - -/// The menu bar glyph. -/// -/// Drawn as vector paths rather than shipped as PNGs, so it stays crisp at every scale -/// factor and inverts correctly as a template image. -/// -/// Colour is deliberately absent here. macOS menu bar items are monochrome by -/// convention, and a coloured dot up there is the tell of an app that does not respect -/// the platform. State is carried by fill and by a notch instead. The coloured dot lives -/// inside the popover, where it sits beside a word and so never encodes meaning by -/// colour alone. -public enum StatusIcon { - public static let size = NSSize(width: 17, height: 17) - - public static func image(for state: ProxyState) -> NSImage { - switch state { - case .running(let health) where health.isProtected: - return mark(filled: true, notched: false, alpha: 1) - case .running: - return mark(filled: true, notched: true, alpha: 1) - case .loading, .degraded: - return mark(filled: false, notched: false, alpha: 1) - case .unreachable, .unauthorized: - return mark(filled: false, notched: false, alpha: 0.4) - } - } - - /// A rounded mark reduced to menu bar scale. - /// - /// The notch is carved out of the geometry with an even-odd path rather than by - /// compositing. An earlier version stroked with `.clear` and `.clear` composite mode, - /// which silently did nothing — the rendered at-risk glyph was indistinguishable from - /// the protected one, so the state signal was invisible. - private static func mark(filled: Bool, notched: Bool, alpha: CGFloat) -> NSImage { - let image = NSImage(size: size, flipped: false) { rect in - let inset = rect.insetBy(dx: 2.5, dy: 2.5) - let path = NSBezierPath(roundedRect: inset, xRadius: 4, yRadius: 4) - - if notched { - // A slot carved out of the trailing edge, kept fully inside the mark so - // the silhouette stays clean. Even-odd winding turns the subpath into a - // hole rather than a second filled shape. - let notch = NSBezierPath( - roundedRect: NSRect( - x: inset.maxX - 4.2, - y: inset.midY - 1.1, - width: 3.0, - height: 2.2 - ), - xRadius: 1.1, - yRadius: 1.1 - ) - path.append(notch) - path.windingRule = .evenOdd - } - - NSColor.black.withAlphaComponent(alpha).setStroke() - NSColor.black.withAlphaComponent(alpha).setFill() - - if filled { - path.fill() - } else { - path.lineWidth = 1.6 - path.stroke() - } - return true - } - image.isTemplate = true - return image - } -} diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift deleted file mode 100644 index b1e8b4241f4..00000000000 --- a/app/Sources/MenuBarUI/Theme.swift +++ /dev/null @@ -1,103 +0,0 @@ -import AppKit - -/// Tokens derived from `gui/src/styles.css` so the companion and the dashboard agree on -/// what "healthy" looks like. -/// -/// For SURFACES, AppKit's semantic colours win over a hardcoded hex: they track -/// light/dark plus the increased-contrast and vibrancy accessibility settings, which a -/// literal cannot. -/// -/// The TEXT tiers are a deliberate exception. Measured against the popover material, -/// `tertiaryLabelColor` renders at 2.01:1 in light and 2.39:1 in dark — it is designed -/// for disabled affordances, not for information the user has to read. All four text and -/// mark tokens below are therefore calibrated against the rendered material and verified -/// numerically rather than trusted by name. -enum Theme { - // Surfaces - static let separator = NSColor.separatorColor - static let raised = NSColor.controlBackgroundColor - - // Text: --text / --muted / --faint - // - // `tertiaryLabelColor` measured 2.01:1 in light and 2.39:1 in dark against the - // popover material — well under the 4.5:1 required for normal text. AppKit's - // tertiary tier is intended for disabled affordances, not for information the user - // has to read, and every label using this tier here (range heading, metric captions, - // quota window labels) carries real meaning. Calibrated tokens replace it. - /// All three text tiers are calibrated against the RENDERED popover material, not - /// picked from AppKit's semantic palette. Measured backgrounds: light (220,219,218), - /// dark (102,101,101). - /// - /// The dark material constrains this hard — pure white measures only 5.81:1 against - /// it — so the tiers are packed into the band that remains while keeping every text - /// tier above 4.5:1 and preserving `text > muted > faint` in both appearances. - static let text = dynamic(light: 0x1A1A1A, dark: 0xFFFFFF) - static let muted = dynamic(light: 0x3D3D3D, dark: 0xF2F2F2) - /// Small supporting text that must still be legible: 10-11pt captions and labels. - static let faint = dynamic(light: 0x545454, dark: 0xEDEDED) - /// Graphical marks only, held to the 3:1 non-text threshold rather than 4.5:1. - static let graphMark = dynamic(light: 0x707070, dark: 0xD2D2D2) - - // State colours, taken verbatim from styles.css. - static let green = dynamic(light: 0x0A7D5C, dark: 0x4ECB9D) - static let amber = dynamic(light: 0x9A4A08, dark: 0xFBBF24) - static let red = dynamic(light: 0xB91C1C, dark: 0xF87171) - - // Type ladder: --text-micro / --text-caption / --text-label / --text-control. - static let micro = NSFont.systemFont(ofSize: 10, weight: .medium) - static let caption = NSFont.systemFont(ofSize: 11) - static let label = NSFont.systemFont(ofSize: 12, weight: .semibold) - /// Monospaced digits are the AppKit equivalent of `font-variant-numeric: tabular-nums`. - /// Without this, polling makes every digit jitter. - static let numeric = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) - static let numericSmall = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) - - // Geometry: --space-* and --radius-sm. - static let gutter: CGFloat = 12 - static let rowGap: CGFloat = 8 - static let tightGap: CGFloat = 4 - static let radius: CGFloat = 8 - static let width: CGFloat = 340 - - static func color(for tone: ProxyToneBridge) -> NSColor { - switch tone { - case .neutral: return muted - case .good: return green - case .warning: return amber - case .bad: return red - } - } - - /// Quota fill: green under 80, amber to 95, red above. The percentage is always - /// printed beside the bar, so colour is reinforcement rather than the only signal. - static func quotaColor(percent: Double?) -> NSColor { - guard let percent else { return faint } - if percent > 95 { return red } - if percent >= 80 { return amber } - return green - } - - /// `light-dark()` equivalent: resolves per appearance instead of at creation time. - private static func dynamic(light: Int, dark: Int) -> NSColor { - NSColor(name: nil) { appearance in - let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - return NSColor(hex: isDark ? dark : light) - } - } -} - -/// Mirrors `ProxyState.Tone` without importing AppKit into the core module. -enum ProxyToneBridge { - case neutral, good, warning, bad -} - -extension NSColor { - convenience init(hex: Int) { - self.init( - srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, - green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, - alpha: 1 - ) - } -} diff --git a/app/Sources/MenuBarUI/TimelineChartView.swift b/app/Sources/MenuBarUI/TimelineChartView.swift deleted file mode 100644 index 56a8a6288f1..00000000000 --- a/app/Sources/MenuBarUI/TimelineChartView.swift +++ /dev/null @@ -1,135 +0,0 @@ -import AppKit -import MenuBarCore - -public final class TimelineChartView: NSView { - private var timeline: UsageTimeline? - private var settings = CompanionSettings.defaults - private let colors = [0x0A84FF, 0xFF9F0A, 0x30D158, 0xBF5AF2, 0xFF453A, 0x64D2FF] - - public override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: 104) - } - - public func apply(_ snapshot: ProxySnapshot) { - settings = snapshot.settings - timeline = snapshot.timeline - isHidden = !settings.showChart || timeline == nil - setAccessibilityLabel("Usage timeline") - needsDisplay = true - } - - public override func draw(_ dirtyRect: NSRect) { - guard let timeline, !timeline.isEmpty else { - if settings.showChart { - drawText("No token usage in this window.", in: NSRect(x: 0, y: 36, width: bounds.width, height: 16), font: Theme.caption, color: Theme.muted) - } - return - } - let chartHeight: CGFloat = 72 - let maxValue = settings.chartStyle == .stackedBar ? timeline.stackedMax : timeline.maxPoint - drawText(Format.tokens(Int(maxValue.rounded())), in: NSRect(x: 0, y: chartHeight + 8, width: bounds.width, height: 14), font: Theme.micro, color: Theme.muted, alignment: .right) - let window = timeline.buckets * timeline.bucketSeconds / 3600 - let windowLabel: String - if window < 48 { - windowLabel = "\(window)h" - } else { - windowLabel = "\(window / 24)d" - } - drawText(windowLabel, in: NSRect(x: 0, y: chartHeight + 8, width: 40, height: 14), font: Theme.micro, color: Theme.muted) - - let plot = NSRect(x: 0, y: 20, width: bounds.width, height: chartHeight) - Theme.muted.setStroke() - let baseline = NSBezierPath() - baseline.move(to: NSPoint(x: plot.minX, y: plot.minY)) - baseline.line(to: NSPoint(x: plot.maxX, y: plot.minY)) - baseline.lineWidth = 0.5 - baseline.stroke() - - if settings.chartStyle == .stackedBar { - drawBars(timeline, in: plot, maxValue: maxValue) - } else { - drawLines(timeline, in: plot, maxValue: maxValue) - } - - drawLegend(timeline, in: NSRect(x: 0, y: 0, width: bounds.width, height: 14)) - } - - private func drawText( - _ text: String, in rect: NSRect, font: NSFont, color: NSColor, alignment: NSTextAlignment = .left - ) { - let style = NSMutableParagraphStyle() - style.alignment = alignment - NSAttributedString( - string: text, - attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style] - ).draw(in: rect) - } - - private func drawLines(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { - guard timeline.buckets > 1, maxValue > 0 else { return } - for (seriesIndex, series) in timeline.series.enumerated() { - let path = NSBezierPath() - for (index, value) in series.points.enumerated() { - let x = plot.minX + plot.width * CGFloat(index) / CGFloat(max(timeline.buckets - 1, 1)) - let y = plot.minY + plot.height * CGFloat(value / maxValue) - if index == 0 { path.move(to: NSPoint(x: x, y: y)) } else { path.line(to: NSPoint(x: x, y: y)) } - } - NSColor(hex: colors[seriesIndex % colors.count]).setStroke() - path.lineWidth = 1.5 - path.stroke() - } - } - - private func drawBars(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { - guard timeline.buckets > 0, maxValue > 0 else { return } - let width = max(1, plot.width / CGFloat(timeline.buckets) - 1) - for bucket in 0.. 0 { - let extra = entries.count - visible - let suffixWidth = extra > 0 - ? NSAttributedString(string: "+\(extra) more", attributes: attributes).size().width + separator - : 0 - let entryWidth = entries.prefix(visible).reduce(CGFloat.zero) { width, entry in - width + dotSize + 4 + entry.1.size().width + separator - } - if entryWidth + suffixWidth <= rect.width || visible == 0 { break } - visible -= 1 - } - let extra = entries.count - visible - var x = rect.minX - for (index, text) in entries.prefix(visible) { - let dot = NSRect(x: x, y: rect.midY - dotSize / 2, width: dotSize, height: dotSize) - NSColor(hex: colors[index % colors.count]).setFill() - NSBezierPath(ovalIn: dot).fill() - x += dotSize + 4 - text.draw(at: NSPoint(x: x, y: rect.minY)) - x += text.size().width + separator - } - if extra > 0 { - NSAttributedString(string: "+\(extra) more", attributes: attributes) - .draw(at: NSPoint(x: x, y: rect.minY)) - } - } -} diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift deleted file mode 100644 index 70c229b6859..00000000000 --- a/app/Sources/MenuBarUI/Views.swift +++ /dev/null @@ -1,264 +0,0 @@ -import AppKit -import MenuBarCore - -// MARK: - Shared helpers - -func makeLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField { - let field = NSTextField(labelWithString: text) - field.font = font - field.textColor = color - field.lineBreakMode = .byTruncatingTail - return field -} - -func makeRow(_ views: [NSView], spacing: CGFloat = Theme.rowGap) -> NSStackView { - let stack = NSStackView(views: views) - stack.orientation = .horizontal - stack.spacing = spacing - stack.alignment = .firstBaseline - return stack -} - -func makeSeparator() -> NSView { - let line = NSView() - line.wantsLayer = true - line.layer?.backgroundColor = Theme.separator.cgColor - line.translatesAutoresizingMaskIntoConstraints = false - line.heightAnchor.constraint(equalToConstant: 1).isActive = true - return line -} - -// MARK: - Status header - -/// `● Running 127.0.0.1:10100` -/// -/// The dot never travels alone: the word beside it carries the same meaning, so the UI -/// stays readable without colour perception (WCAG 1.4.1). -final class StatusHeaderView: NSView { - private let dot = StatusDotView() - private let title = makeLabel("", font: Theme.label, color: Theme.text) - private let endpoint = makeLabel("", font: Theme.caption, color: Theme.muted) - private let detail = makeLabel("", font: Theme.caption, color: Theme.muted) - - init() { - super.init(frame: .zero) - let top = makeRow([dot, title, NSView(), endpoint], spacing: Theme.rowGap) - top.alignment = .centerY - top.distribution = .fill - endpoint.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - let stack = NSStackView(views: [top, detail]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 2 - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), - stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), - stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - let state = snapshot.state - title.stringValue = state.title - endpoint.stringValue = snapshot.endpoint.display - dot.tone = bridge(state.tone) - - if let text = state.detail { - detail.stringValue = text - detail.isHidden = false - } else { - detail.isHidden = true - } - - setAccessibilityLabel("Proxy \(state.title) at \(snapshot.endpoint.display)") - } - - private func bridge(_ tone: ProxyState.Tone) -> ProxyToneBridge { - switch tone { - case .neutral: return .neutral - case .good: return .good - case .warning: return .warning - case .bad: return .bad - } - } -} - -final class StatusDotView: NSView { - var tone: ProxyToneBridge = .neutral { - didSet { needsDisplay = true } - } - - override var intrinsicContentSize: NSSize { NSSize(width: 8, height: 8) } - - override func draw(_ dirtyRect: NSRect) { - let rect = NSRect(x: 0, y: (bounds.height - 8) / 2, width: 8, height: 8) - Theme.color(for: tone).setFill() - NSBezierPath(ovalIn: rect).fill() - } -} - -// MARK: - Metrics - -/// Three columns plus a range header that echoes the response, never the request. -final class MetricsView: NSView { - private let rangeLabel = makeLabel("USAGE", font: Theme.micro, color: Theme.faint) - private let columns: [(caption: NSTextField, value: NSTextField)] - private let emptyLabel = makeLabel("", font: Theme.caption, color: Theme.muted) - private let stack: NSStackView - private let columnsRow: NSStackView - - init() { - let captions = ["TOKENS", "REQUESTS", "COST"] - columns = captions.map { caption in - (makeLabel(caption, font: Theme.micro, color: Theme.faint), - makeLabel(Format.unknown, font: Theme.numeric, color: Theme.text)) - } - - let columnViews: [NSView] = columns.map { pair in - let column = NSStackView(views: [pair.caption, pair.value]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = 1 - return column - } - columnsRow = NSStackView(views: columnViews) - columnsRow.orientation = .horizontal - columnsRow.distribution = .fillEqually - columnsRow.alignment = .top - - stack = NSStackView(views: [rangeLabel, columnsRow, emptyLabel]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - - super.init(frame: .zero) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), - stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), - stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - let usage = snapshot.today ?? snapshot.usage - isHidden = !snapshot.settings.showToday - rangeLabel.stringValue = usage?.rangeLabel ?? "USAGE" - columnsRow.arrangedSubviews[2].isHidden = !snapshot.settings.showCost - - // Three states: known-empty gets copy, unknown gets em dashes, data gets values. - switch snapshot.usageIsEmpty { - case .some(true): - columnsRow.isHidden = true - emptyLabel.isHidden = false - emptyLabel.stringValue = "No requests in this period." - default: - columnsRow.isHidden = false - emptyLabel.isHidden = true - let summary = usage?.summary - let requests = Format.count(summary?.requests) - columns[0].value.stringValue = Format.tokens(summary?.totalTokens) - columns[1].value.stringValue = (summary?.hasEstimates ?? false) ? requests + "~" : requests - columns[2].value.stringValue = Format.cost(summary?.estimatedCostUsd) - columns[0].value.setAccessibilityLabel( - "\(Format.tokens(summary?.totalTokens)) tokens" - ) - columns[1].value.setAccessibilityLabel( - (summary?.hasEstimates ?? false) - ? "\(requests) requests, partly estimated" - : "\(requests) requests" - ) - columns[2].value.setAccessibilityLabel( - "\(Format.cost(summary?.estimatedCostUsd)) estimated cost" - ) - } - } -} - -// MARK: - Quotas - -/// `OpenAI ▓▓▓▓▓░░░░░ 44%` -final class QuotaRowView: NSView { - init(quota: NormalizedQuota) { - super.init(frame: .zero) - - let name = makeLabel(quota.providerLabel, font: Theme.caption, color: Theme.text) - name.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - name.lineBreakMode = .byTruncatingTail - - // Which window a number belongs to is not decoration: 42% of an API-usage window - // and 42% of a month mean very different things. - let window = makeLabel( - quota.hasPercent ? quota.windowLabel : "", - font: Theme.micro, color: Theme.faint - ) - - let labels = NSStackView(views: [name, window]) - labels.orientation = .vertical - labels.alignment = .leading - labels.spacing = 0 - - let bar = QuotaBarView() - bar.percent = quota.percent - - let value = makeLabel(Format.percent(quota.percent), font: Theme.numericSmall, color: Theme.muted) - value.alignment = .right - - let row = NSStackView(views: [labels, bar, value]) - row.orientation = .horizontal - row.spacing = Theme.rowGap - row.alignment = .centerY - row.translatesAutoresizingMaskIntoConstraints = false - addSubview(row) - - NSLayoutConstraint.activate([ - row.topAnchor.constraint(equalTo: topAnchor), - row.leadingAnchor.constraint(equalTo: leadingAnchor), - row.trailingAnchor.constraint(equalTo: trailingAnchor), - row.bottomAnchor.constraint(equalTo: bottomAnchor), - labels.widthAnchor.constraint(equalToConstant: 132), - value.widthAnchor.constraint(equalToConstant: 36), - ]) - - // The percentage is spoken, not merely drawn as a filled width. - let reset = Format.resetsIn(quota.resetAt) - setAccessibilityLabel( - quota.hasPercent - ? "\(quota.providerLabel): \(Format.percent(quota.percent)) of \(quota.windowLabel) quota, resets in \(reset)" - : "\(quota.providerLabel): quota unknown" - ) - } - - required init?(coder: NSCoder) { nil } -} - -final class QuotaBarView: NSView { - var percent: Double? - - override var intrinsicContentSize: NSSize { NSSize(width: 110, height: 6) } - - override func draw(_ dirtyRect: NSRect) { - let track = NSRect(x: 0, y: (bounds.height - 6) / 2, width: bounds.width, height: 6) - Theme.raised.setFill() - NSBezierPath(roundedRect: track, xRadius: 3, yRadius: 3).fill() - - // A nil percent draws no fill at all — a zero-width bar would read as "0% used", - // which is a different fact from "unknown". - guard let percent else { return } - let clamped = max(0, min(100, percent)) - guard clamped > 0 else { return } - let fill = NSRect(x: 0, y: track.origin.y, width: track.width * CGFloat(clamped / 100), height: 6) - Theme.quotaColor(percent: percent).setFill() - NSBezierPath(roundedRect: fill, xRadius: 3, yRadius: 3).fill() - } -} diff --git a/app/Sources/MenuBarUITests/Harness.swift b/app/Sources/MenuBarUITests/Harness.swift deleted file mode 100644 index 0deb1d6ae46..00000000000 --- a/app/Sources/MenuBarUITests/Harness.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation - -/// A dependency-free assertion harness. -/// -/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line -/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles -/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to -/// run the unit tests of a menu bar companion would put the tests out of reach for most -/// contributors and for any CI runner without Xcode selected. -/// -/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that -/// both a human and CI can read. If the package ever gains a full-Xcode requirement for -/// other reasons, migrating these cases to swift-testing is mechanical. -public struct TestFailure { - let test: String - let message: String - let file: String - let line: Int -} - -public final class TestRunner { - private(set) var passed = 0 - private(set) var failures: [TestFailure] = [] - private var current = "" - - public init() {} - - public func test(_ name: String, _ body: () throws -> Void) { - current = name - let failuresBefore = failures.count - do { - try body() - } catch { - failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) - print("FAIL — \(name): threw \(error)") - return - } - // A case that recorded an expectation failure is not a pass, even though its - // body returned normally. - if failures.count == failuresBefore { - passed += 1 - print("ok — \(name)") - } - } - - public func expect( - _ condition: Bool, - _ message: @autoclosure () -> String, - file: String = #file, - line: Int = #line - ) { - guard !condition else { return } - let failure = TestFailure(test: current, message: message(), file: file, line: line) - failures.append(failure) - print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") - } - - public func equal( - _ actual: T, - _ expected: T, - _ label: String = "", - file: String = #file, - line: Int = #line - ) { - expect( - actual == expected, - "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", - file: file, - line: line - ) - } - - public func notNil( - _ value: T?, - _ label: String, - file: String = #file, - line: Int = #line - ) -> T? { - expect(value != nil, "\(label) should not be nil", file: file, line: line) - return value - } - - public func isNil( - _ value: T?, - _ label: String, - file: String = #file, - line: Int = #line - ) { - expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) - } - - /// Prints the summary and returns the process exit code. - public func summarize() -> Int32 { - print("") - if failures.isEmpty { - print("\(passed) passed, 0 failed") - return 0 - } - print("\(passed) passed, \(failures.count) FAILED") - for failure in failures { - print(" - \(failure.test): \(failure.message)") - } - return 1 - } -} diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift deleted file mode 100644 index daf7e872b20..00000000000 --- a/app/Sources/MenuBarUITests/main.swift +++ /dev/null @@ -1,165 +0,0 @@ -import AppKit -import MenuBarCore -import MenuBarUI - -// UI-layer tests. Separate from MenuBarCoreTests because these need AppKit and an -// NSApplication; the core suite deliberately has no UI dependency. -// -// These cover the Phase 3 behaviours that were defects in earlier review rounds: -// optimistic rollback, pending state surviving a poll, and the direction-sensitive -// default-provider guard. - -let app = NSApplication.shared -app.setActivationPolicy(.prohibited) - -let runner = TestRunner() - -func provider(_ name: String, enabled: Bool = true) -> ProviderSummary { - let json = #"{"name":"\#(name)","disabled":\#(enabled ? "false" : "true")}"# - return try! JSONDecoder().decode(ProviderSummary.self, from: Data(json.utf8)) -} - -func snapshot( - providers: [ProviderSummary], - defaultProvider: String? = "openai" -) -> ProxySnapshot { - ProxySnapshot( - state: .running(StartupHealth(status: "protected")), - endpoint: .default, - providers: providers, - defaultProvider: defaultProvider, - lastUpdated: Date(), - providersLoaded: true - ) -} - -// MARK: - Default-provider guard direction - -runner.test("ui: an enabled default provider cannot be switched off") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("openai"), provider("anthropic")])) - list.expandForTesting() - - runner.equal(list.isToggleEnabled("openai"), false, "enabled default is inert") - runner.equal(list.isToggleEnabled("anthropic"), true, "non-default is toggleable") -} - -// The proxy guard is `disabled && name === defaultProvider`, so ENABLING the default is -// valid. Making the control inert whenever isDefault stranded the user. -runner.test("ui: a disabled default provider can still be switched back on") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("openai", enabled: false)])) - list.expandForTesting() - - runner.equal(list.isToggleEnabled("openai"), true, "disabled default must be recoverable") -} - -// MARK: - Optimistic update and rollback - -runner.test("ui: a rejected write restores the switch it moved") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - // User switches it off; the write is in flight. - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") - runner.equal(list.isToggleEnabled("anthropic"), false, "inert while in flight") - - // The proxy rejects it. - list.revert("anthropic", to: true) - runner.equal(list.isToggleOn("anthropic"), true, "reverted to the server's value") - runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") -} - -runner.test("ui: a successful write clears busy without reverting") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - // The authoritative refresh now reports it disabled. - list.apply(snapshot(providers: [provider("anthropic", enabled: false)])) - list.setBusy("anthropic", false) - - runner.equal(list.isToggleOn("anthropic"), false, "server state retained") - runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") -} - -// MARK: - Pending state versus a stale poll - -// This is the defect a reviewer caught: rebuildRows initialised each switch from the -// snapshot, so a poll carrying pre-write data snapped the switch back mid-write. -runner.test("ui: a stale poll cannot undo an in-flight optimistic change") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") - - // A poll that started before the write lands, still reporting the old value. - list.apply(snapshot(providers: [provider("anthropic", enabled: true)])) - - runner.equal(list.isToggleOn("anthropic"), false, "stale poll must not snap it back") - runner.equal(list.isToggleEnabled("anthropic"), false, "still inert while in flight") -} - -runner.test("ui: pending state is per provider and does not leak") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic"), provider("xai")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleEnabled("anthropic"), false, "target is inert") - runner.equal(list.isToggleEnabled("xai"), true, "sibling is unaffected") - runner.equal(list.isToggleOn("xai"), true, "sibling keeps its value") -} - -// MARK: - Empty and unloaded states - -runner.test("ui: providers are hidden until they have actually been read") { - let list = ProviderListView() - var unloaded = snapshot(providers: []) - unloaded.providersLoaded = false - list.apply(unloaded) - runner.equal(list.isHidden, true, "not fetched yet is not the same as none") - - list.apply(snapshot(providers: [])) - runner.equal(list.isHidden, false, "an empty result renders its own copy") -} - -runner.test("ui: hidden providers do not create rows") { - let list = ProviderListView() - var current = snapshot(providers: [provider("openai"), provider("anthropic")]) - current.settings = CompanionSettings(hiddenProviders: ["openai"]) - list.apply(current) - list.expandForTesting() - runner.equal(list.hasProviderForTesting("openai"), false) - runner.equal(list.hasProviderForTesting("anthropic"), true) -} - -runner.test("ui: chart setting hides the timeline view") { - let chart = TimelineChartView() - var current = snapshot(providers: []) - current.timeline = try! JSONDecoder().decode( - UsageTimeline.self, - from: Data(#"{"start":0,"end":1,"bucketSeconds":1,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"#.utf8) - ) - current.settings = CompanionSettings(showChart: false) - chart.apply(current) - runner.equal(chart.isHidden, true) -} - -runner.test("ui: menu title renders from a companion template") { - let report = try! JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":3}}"#.utf8) - ) - var current = snapshot(providers: []) - current.today = report - current.settings = CompanionSettings(menuBarTemplate: "req {requests}") - runner.equal(current.menuBarTitle, "req 3") -} - -exit(runner.summarize()) diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift index b130d1fda59..edc91171f44 100644 --- a/app/Sources/OpenCodexWidget/Views.swift +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -293,8 +293,8 @@ struct OpenCodexWidgetView: View { Image(systemName: failure == .missing ? "rectangle.on.rectangle" : "exclamationmark.triangle") .font(.title2) Text(failure == .missing - ? "Open the OpenCodex menu bar app to start sharing usage." - : "Snapshot unreadable — refresh from the menu bar app.") + ? "Open the OpenCodex desktop app to start sharing usage." + : "Snapshot unreadable — refresh from the desktop app.") .font(.caption) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift deleted file mode 100644 index 02eca1592b3..00000000000 --- a/app/Sources/UIProbe/main.swift +++ /dev/null @@ -1,165 +0,0 @@ -// Visual-QA harness (not shipped). -// -// Presents the real PopoverPanel over a deliberately loud backdrop and captures it with -// CGWindowListCreateImage, so every UI state can be inspected without depending on free -// menu bar space. -// -// Two harness decisions are load-bearing, both learned the hard way: -// * Present through the REAL panel. An earlier version used a plain NSWindow, which -// supplied its own background and hid the fact that the panel had none at all. -// * Capture through the window server. cacheDisplay(in:to:) skips text rendering and -// produced screenshots with no labels. -// -// PROBE_STATE: live | stopped | unauthorized | loading | degraded | empty | overflow -// PROBE_TAG: output filename suffix -// PROBE_APPEARANCE: light | dark (forces appearance without touching system settings) - -import AppKit -import MenuBarCore -import MenuBarUI - -// Presents the real PopoverPanel over a contrasting backdrop and captures it through the -// window server, so the UI can be inspected without depending on menu bar space. -final class ProbeDelegate: NSObject, NSApplicationDelegate { - let controller = PopoverViewController() - var window: NSWindow? - - func applicationDidFinishLaunching(_ n: Notification) { - // Force an appearance for contrast measurement without touching system settings. - if let name = ProcessInfo.processInfo.environment["PROBE_APPEARANCE"] { - NSApp.appearance = NSAppearance(named: name == "dark" ? .darkAqua : .aqua) - } - let endpoint = ProxyDiscovery.resolve() - let client = ProxyClient(endpoint: endpoint) - let coordinator = PollingCoordinator(client: client, endpoint: endpoint) - - // A loud backdrop first: if the panel has no surface of its own, this shows - // straight through and the defect is unmissable. - let backdrop = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 620), - styleMask: [.titled], backing: .buffered, defer: false) - backdrop.title = "backdrop" - let strip = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 620)) - strip.wantsLayer = true - strip.layer?.backgroundColor = NSColor.systemRed.cgColor - for i in 0..<14 { - let bar = NSView(frame: NSRect(x: 0, y: CGFloat(i) * 44, width: 520, height: 22)) - bar.wantsLayer = true - bar.layer?.backgroundColor = NSColor.systemYellow.cgColor - strip.addSubview(bar) - } - backdrop.contentView = strip - backdrop.center() - backdrop.makeKeyAndOrderFront(nil) - - // Present through the real panel so its surface (or absence of one) is captured. - let realPanel = PopoverPanel() - realPanel.contentViewController = controller - controller.view.layoutSubtreeIfNeeded() - let size = controller.preferredContentSize - realPanel.setContentSize(NSSize(width: 340, height: max(size.height, 200))) - realPanel.setFrameOrigin(NSPoint(x: backdrop.frame.midX - 170, y: backdrop.frame.midY - 150)) - realPanel.makeKeyAndOrderFront(nil) - window = realPanel - NSApp.activate(ignoringOtherApps: true) - - Task { - var snap: ProxySnapshot - let mode = ProcessInfo.processInfo.environment["PROBE_STATE"] ?? "live" - switch mode { - case "stopped": - snap = ProxySnapshot(state: .unreachable, endpoint: endpoint, - lastKnownStartCommand: "ocx service start") - case "unauthorized": - snap = ProxySnapshot(state: .unauthorized, endpoint: endpoint) - case "loading": - snap = ProxySnapshot(state: .loading, endpoint: endpoint) - case "degraded": - snap = ProxySnapshot(state: .degraded("The proxy returned an unexpected status (503)."), - endpoint: endpoint, lastUpdated: Date().addingTimeInterval(-120)) - case "overflow": - let many = (1...24).map { i in - #"{"provider":"p\#(i)","label":"Provider \#(i)","quota":{"weeklyPercent":\#(i * 3)}}"# - }.joined(separator: ",") - let quotas = (try? JSONDecoder().decode([QuotaReport].self, from: Data("[\(many)]".utf8))) ?? [] - let usage = try? JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":100,"totalTokens":1200},"models":[{"provider":"p","model":"m","requests":100,"totalTokens":1200}]}"#.utf8)) - let timeline = try? JSONDecoder().decode( - UsageTimeline.self, - from: Data(#"{"start":0,"end":3600,"bucketSeconds":900,"buckets":4,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"p/m","provider":"p","model":"m","total":1200,"points":[100,200,300,600]}],"availableModels":["p/m"],"missingMeasurements":0}"#.utf8)) - snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, settings: CompanionSettings(menuBarMetric: .tokens), - today: usage, timeline: timeline, - quotas: quotas, - quotasLoaded: true) - case "empty": - let usage = try? JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":0},"models":[],"accounts":[]}"#.utf8)) - snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, today: usage, quotas: [], providers: [], - providersLoaded: true, quotasLoaded: true) - default: - await coordinator.setPopoverOpen(true) - snap = await coordinator.current - } - await MainActor.run { - self.controller.apply(snap) - // Expand the provider list so its toggles are visible in the capture. - if ProcessInfo.processInfo.environment["PROBE_EXPAND"] == "1" { - self.expandProviders(in: self.controller.view) - } - if ProcessInfo.processInfo.environment["PROBE_RESULT"] != nil { - self.controller.showResult( - ProcessInfo.processInfo.environment["PROBE_RESULT"]!, - isError: ProcessInfo.processInfo.environment["PROBE_RESULT_ERROR"] == "1") - } - self.controller.view.layoutSubtreeIfNeeded() - // Match the real popover: size to content instead of a fixed frame. - let h = self.controller.preferredContentSize.height - if h > 0, let w = self.window { - w.setContentSize(NSSize(width: 340, height: h)) - } - } - try? await Task.sleep(nanoseconds: 1_200_000_000) - await MainActor.run { self.capture() } - } - } - - @MainActor func expandProviders(in view: NSView) { - for sub in view.subviews { - if let button = sub as? NSButton, button.bezelStyle == .disclosure { - button.state = .on - if let target = button.target, let action = button.action { - _ = target.perform(action, with: button) - } - } - expandProviders(in: sub) - } - } - - @MainActor func capture() { - guard let w = window else { return } - let tag = ProcessInfo.processInfo.environment["PROBE_TAG"] ?? "light" - // CGWindowListCreateImage rather than shelling out to screencapture: nothing - // under app/ may construct a Process (030 security rule). The bitmap-rep path - // is not an option either — it skips text rendering entirely. - let id = CGWindowID(w.windowNumber) - if let cg = CGWindowListCreateImage( - .null, .optionIncludingWindow, id, [.boundsIgnoreFraming, .bestResolution] - ) { - let rep = NSBitmapImageRep(cgImage: cg) - if let png = rep.representation(using: .png, properties: [:]) { - try? png.write(to: URL(fileURLWithPath: "/tmp/popover-\(tag).png")) - } - } - NSApp.terminate(nil) - } -} - -let app = NSApplication.shared -app.setActivationPolicy(.regular) -let d = ProbeDelegate() -app.delegate = d -app.run() diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist index 360a1591031..568e5fce750 100644 --- a/app/Widget-Info.plist +++ b/app/Widget-Info.plist @@ -4,7 +4,7 @@ CFBundleDevelopmentRegionen CFBundleExecutableOpenCodexWidget - CFBundleIdentifiercom.opencodex.menubar.widget + CFBundleIdentifiercom.opencodex.desktop.widget CFBundleInfoDictionaryVersion6.0 CFBundleNameOpenCodex CFBundlePackageTypeXPC! diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000000..9106a50f67f --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,69 @@ +# OpenCodex desktop shell + +The Tauri shell attaches to the local OpenCodex proxy and keeps the dashboard +in the proxy's loopback origin. During development: + +```sh +bun run prepare-sidecar +bun run prepare-widget +bunx tauri dev +``` + +The sidecar is generated from the repository's standalone binary build and is +not checked into git. + +The CI desktop-shell job performs Rust-only checks. It creates an empty +platform-named sidecar stub and a placeholder dashboard resource directory +solely for Tauri's external-binary and resource validation; it does not build +or run the standalone binary. + +For a macOS release build, prepare the sidecar and WidgetKit extension before invoking +Tauri: + +```sh +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` + +## Release packaging and updates + +The release workflow builds a macOS DMG, Windows MSI, Linux AppImage, and Debian package. +It collects the platform artifacts beside checksum files and creates `latest.json` for the +Tauri updater. The public updater key and endpoint live in `src-tauri/tauri.conf.json`; +the private key must never be committed. The manifest is generated only when the updater +key secret is configured and then requires all four platforms to be signed. + +To package locally: + +```sh +bun run build:gui +cd desktop +bun install --frozen-lockfile +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build --ci --bundles app,dmg +``` + +Release signing is supplied through environment variables: + +```sh +export TAURI_SIGNING_PRIVATE_KEY="..." +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..." +export APPLE_CERTIFICATE="..." +export APPLE_CERTIFICATE_PASSWORD="..." +export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" +export APPLE_ID="..." +export APPLE_PASSWORD="..." +export APPLE_TEAM_ID="..." +export MACOS_SIGN_IDENTITY="$APPLE_SIGNING_IDENTITY" +``` + +Generate a Tauri updater key pair with: + +```sh +bunx tauri signer generate +``` + +Keep the private key in a local secret store. Windows SmartScreen signing is not wired +yet; the release workflow documents that installers may show an unsigned-publisher warning. diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 00000000000..09668ce09b7 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,13 @@ +{ + "name": "@opencodex/desktop", + "private": true, + "scripts": { + "dev": "tauri dev", + "build": "tauri build", + "prepare-sidecar": "bun scripts/prepare-sidecar.ts", + "prepare-widget": "bash scripts/build-widget.sh" + }, + "devDependencies": { + "@tauri-apps/cli": "2.5.0" + } +} diff --git a/desktop/scripts/build-widget.sh b/desktop/scripts/build-widget.sh new file mode 100755 index 00000000000..59827dcea2a --- /dev/null +++ b/desktop/scripts/build-widget.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "prepare-widget requires macOS." >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +desktop_dir="$(cd "$script_dir/.." && pwd)" +repo_root="$(cd "$desktop_dir/.." && pwd)" +package_dir="$repo_root/app" +output_dir="$desktop_dir/src-tauri/widget/OpenCodexWidget.appex" +configuration="${CONFIGURATION:-release}" +universal="${UNIVERSAL:-1}" + +if [[ "$universal" != "0" && "$universal" != "1" ]]; then + echo "UNIVERSAL must be 0 or 1." >&2 + exit 1 +fi + +build_root="$(mktemp -d "${TMPDIR:-/tmp}/opencodex-widget.XXXXXX")" +cleanup() { rm -rf "$build_root"; } +trap cleanup EXIT + +build_widget() { + local arch="$1" + local scratch="$build_root/$arch" + swift build \ + --package-path "$package_dir" \ + --scratch-path "$scratch" \ + -c "$configuration" \ + --arch "$arch" \ + --product OpenCodexWidget + swift build \ + --package-path "$package_dir" \ + --scratch-path "$scratch" \ + -c "$configuration" \ + --arch "$arch" \ + --show-bin-path +} + +if [[ "$universal" == "1" ]]; then + arm64_bin="$(build_widget arm64 | tail -n 1)/OpenCodexWidget" + x86_64_bin="$(build_widget x86_64 | tail -n 1)/OpenCodexWidget" + executable="$build_root/OpenCodexWidget" + lipo -create "$arm64_bin" "$x86_64_bin" -output "$executable" +else + executable="$(build_widget "$(uname -m)" | tail -n 1)/OpenCodexWidget" +fi + +[[ -x "$executable" ]] || { echo "Swift build did not produce $executable" >&2; exit 1; } + +rm -rf "$output_dir" +mkdir -p "$output_dir/Contents/MacOS" +cp "$executable" "$output_dir/Contents/MacOS/OpenCodexWidget" +cp "$package_dir/Widget-Info.plist" "$output_dir/Contents/Info.plist" + +version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$desktop_dir/src-tauri/tauri.conf.json" | head -n 1)" +version_core="${version%%-*}" +[[ "$version_core" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid Tauri version: $version" >&2 + exit 1 +} +plutil -replace CFBundleShortVersionString -string "$version_core" "$output_dir/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$version_core" "$output_dir/Contents/Info.plist" + +if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + codesign --force --sign "$MACOS_SIGN_IDENTITY" --entitlements "$package_dir/Widget.entitlements" \ + --timestamp "$output_dir" +else + codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ + --timestamp=none "$output_dir" +fi + +echo "$output_dir" diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts new file mode 100644 index 00000000000..f946e1d4a92 --- /dev/null +++ b/desktop/scripts/collect-release-assets.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +type BundleKind = "dmg" | "app.tar.gz" | "msi" | "appimage" | "deb"; + +interface BundleSpec { + kind: BundleKind; + dir: string; + name: string; +} + +const bundlesByTarget: Record = { + "universal-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "aarch64-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "x86_64-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "x86_64-pc-windows-msvc": [{ kind: "msi", dir: "msi", name: "windows-x64.msi" }], + "x86_64-unknown-linux-gnu": [ + { kind: "appimage", dir: "appimage", name: "linux-x86_64.AppImage" }, + { kind: "deb", dir: "deb", name: "linux-amd64.deb" }, + ], +}; + +export interface CollectReleaseAssetsOptions { + version: string; + target: string; + out: string; + repoRoot?: string; +} + +function findBundle(directory: string, kind: BundleKind): string { + if (!existsSync(directory)) { + throw new Error(`Missing ${kind} bundle directory: ${directory}`); + } + const artifact = readdirSync(directory) + .filter(name => name.toLowerCase().endsWith(`.${kind.toLowerCase()}`)); + if (artifact.length === 0) throw new Error(`No ${kind} bundle found in ${directory}`); + if (artifact.length > 1) { + throw new Error(`Multiple ${kind} bundles found in ${directory}: ${artifact.join(", ")}`); + } + return join(directory, artifact[0]); +} + +export function collectReleaseAssets(options: CollectReleaseAssetsOptions): string[] { + const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); + const bundles = bundlesByTarget[options.target]; + if (!bundles) throw new Error(`Unsupported desktop target: ${options.target}`); + + const output = resolve(options.out); + mkdirSync(output, { recursive: true }); + const written: string[] = []; + for (const bundle of bundles) { + const source = findBundle( + join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle", bundle.dir), + bundle.kind, + ); + const destinationName = `OpenCodex-${options.version}-${bundle.name}`; + const destination = join(output, destinationName); + copyFileSync(source, destination); + written.push(destination); + + const signature = `${source}.sig`; + if (existsSync(signature)) { + copyFileSync(signature, `${destination}.sig`); + written.push(`${destination}.sig`); + } + + const digest = createHash("sha256").update(readFileSync(destination)).digest("hex"); + const checksum = `${destination}.sha256`; + writeFileSync(checksum, `${digest} ${destinationName}\n`); + written.push(checksum); + } + return written; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const target = argument("--target"); + const out = argument("--out"); + if (!version || !target || !out) { + throw new Error("Usage: collect-release-assets.ts --version --target --out "); + } + for (const path of collectReleaseAssets({ version, target, out })) console.log(`Wrote ${path}`); +} diff --git a/desktop/scripts/prepare-sidecar.ts b/desktop/scripts/prepare-sidecar.ts new file mode 100644 index 00000000000..502127870dc --- /dev/null +++ b/desktop/scripts/prepare-sidecar.ts @@ -0,0 +1,59 @@ +import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const targetByTriple: Record = { + "aarch64-apple-darwin": "bun-darwin-arm64", + "x86_64-apple-darwin": "bun-darwin-x64", + "x86_64-pc-windows-msvc": "bun-windows-x64", + "x86_64-unknown-linux-gnu": "bun-linux-x64", + "aarch64-unknown-linux-gnu": "bun-linux-arm64", +}; + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +function hostTriple(): string | undefined { + const result = Bun.spawnSync(["rustc", "-vV"], { stdout: "pipe", stderr: "ignore" }); + if (result.exitCode !== 0) return undefined; + const host = result.stdout.toString().match(/^host:\s*(\S+)$/m)?.[1]; + return host; +} + +const repoRoot = resolve(import.meta.dir, "../.."); +const triple = + argument("--target") ?? + process.env.TARGET ?? + process.env.RUST_TARGET ?? + Bun.env.RUST_TARGET ?? + hostTriple(); +if (!triple || !targetByTriple[triple]) { + throw new Error( + `Unsupported Rust target ${triple ?? "(host unavailable)"}; pass --target ${Object.keys(targetByTriple).join("|")}`, + ); +} + +const target = targetByTriple[triple]; +const source = join(repoRoot, "dist", "standalone", target); +const executable = join(source, target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"); +if (!existsSync(executable)) { + const result = Bun.spawnSync([ + process.execPath, + "run", + "build:standalone", + "--target", + target, + ], { cwd: repoRoot, stdout: "inherit", stderr: "inherit" }); + if (result.exitCode !== 0) process.exit(result.exitCode); +} + +const desktopRoot = resolve(import.meta.dir, ".."); +const binaries = join(desktopRoot, "src-tauri", "binaries"); +const resources = join(desktopRoot, "src-tauri", "resources", "gui", "dist"); +mkdirSync(binaries, { recursive: true }); +mkdirSync(resources, { recursive: true }); +const destination = join(binaries, `ocx-${triple}${target.startsWith("bun-windows-") ? ".exe" : ""}`); +copyFileSync(executable, destination); +cpSync(join(repoRoot, "gui", "dist"), resources, { recursive: true }); +console.log(`Prepared ${destination}`); diff --git a/desktop/scripts/updater-manifest.ts b/desktop/scripts/updater-manifest.ts new file mode 100644 index 00000000000..e67459d8481 --- /dev/null +++ b/desktop/scripts/updater-manifest.ts @@ -0,0 +1,98 @@ +import { + existsSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +export interface UpdaterManifestOptions { + version: string; + dir: string; + repo: string; + out: string; + warn?: (message: string) => void; + requireAll?: boolean; +} + +interface PlatformUpdate { + signature: string; + url: string; +} + +export interface UpdaterManifest { + version: string; + notes: string; + pub_date: string; + platforms: Record; +} + +const platformFiles: Record = { + "darwin-aarch64": "macos.app.tar.gz", + "darwin-x86_64": "macos.app.tar.gz", + "windows-x86_64": "windows-x64.msi", + "linux-x86_64": "linux-x86_64.AppImage", +}; + +export function buildUpdaterManifest(options: UpdaterManifestOptions): UpdaterManifest { + const dir = resolve(options.dir); + const warn = options.warn ?? console.warn; + const platforms: Record = {}; + const missing: string[] = []; + for (const [platform, suffix] of Object.entries(platformFiles)) { + const base = `OpenCodex-${options.version}-${suffix}`; + const signaturePath = join(dir, `${base}.sig`); + if (!existsSync(signaturePath)) { + missing.push(platform); + if (!options.requireAll) { + warn(`Skipping ${platform}: missing ${signaturePath}`); + } + continue; + } + platforms[platform] = { + signature: readFileSync(signaturePath, "utf8").trim(), + url: `https://github.com/${options.repo}/releases/download/v${options.version}/${base}`, + }; + } + if (options.requireAll && missing.length > 0) { + throw new Error(`Missing signed updater platforms: ${missing.join(", ")}`); + } + if (Object.keys(platforms).length === 0) { + throw new Error("No signed updater platforms remain"); + } + return { + version: options.version, + notes: `https://github.com/${options.repo}/releases/tag/v${options.version}`, + pub_date: new Date().toISOString(), + platforms, + }; +} + +export function writeUpdaterManifest(options: UpdaterManifestOptions): UpdaterManifest { + const manifest = buildUpdaterManifest(options); + const output = resolve(options.out); + const temporary = `${output}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`); + renameSync(temporary, output); + return manifest; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const dir = argument("--dir"); + const repo = argument("--repo"); + const out = argument("--out"); + const requireAll = Bun.argv.includes("--require-all"); + if (!version || !dir || !repo || !out) { + throw new Error( + "Usage: updater-manifest.ts --version --dir --repo --out [--require-all]", + ); + } + writeUpdaterManifest({ version, dir, repo, out, requireAll }); + console.log(`Wrote ${out}`); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock new file mode 100644 index 00000000000..95bc1a674a6 --- /dev/null +++ b/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5654 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.4", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.5", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.1.3", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.9.4", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.5", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.4", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.4", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.4", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.4", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "opencodex-desktop" +version = "2.61.0" +dependencies = [ + "reqwest 0.12.24", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tokio", + "uuid", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.9.4", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.4", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.9.4", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e47d95bc83ed33b2ecf84f4187ad1ab9685d18ff28db000c99deac8ce180e3" +dependencies = [ + "base64 0.21.7", + "chrono", + "hex", + "indexmap 1.9.3", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3cee93715c2e266b9338b7544da68a9f24e227722ba482bd1c024367c77c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.61.2", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sigchld" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.9.4", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.5", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062cdcd483d5e3148c9a64dabf8c574e239e2aa1193cf208d95cf89a676f87a5" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c50a63e60fb8925956cc5b7569f4b750ac197a4d39f13b8dd46ea8e2bad79" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.20", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.12.24", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 0.9.5", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.5", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.5.10", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.7.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 00000000000..8683695613f --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "opencodex-desktop" +version = "2.61.0" +description = "OpenCodex desktop shell" +authors = ["OpenCodex contributors"] +license = "MIT" +edition = "2021" +rust-version = "1.77" + +[lib] +name = "opencodex_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "=2.6.3", features = [] } + +[dependencies] +reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "=1.0.219", features = ["derive"] } +serde_json = "=1.0.140" +uuid = { version = "=1.18.1", features = ["v4"] } +tauri = { version = "=2.11.6", features = ["tray-icon", "image-png"] } +tauri-plugin-autostart = "=2.5.0" +tauri-plugin-opener = "=2.5.3" +tauri-plugin-process = "=2.3.0" +tauri-plugin-shell = "=2.2.0" +tauri-plugin-single-instance = "=2.4.0" +tauri-plugin-updater = "=2.9.0" +tokio = { version = "=1.45.1", features = ["sync", "time"] } + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +strip = "symbols" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 00000000000..d860e1e6a7c --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json new file mode 100644 index 00000000000..622143b1fa4 --- /dev/null +++ b/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Bootstrap-only shell permissions", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-title", + "opener:default", + "autostart:default" + ] +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..e77df1e28fc9cfe97acd00d99810b17fd1fde004 GIT binary patch literal 2849 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 a=rBKLg=%#bB+u{b3(!XyAS)4eQU3xJL{iiM literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c6a3f4f3219f3b8a3f6c2745205598ef57cd2e77 GIT binary patch literal 6300 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 literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..9299f3600476116e06ca002552e861f2bc32c65a GIT binary patch literal 638 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 literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7c578ed553729f8e81b1590f5594ab3b7581f523 GIT binary patch literal 7155 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 KMNUMnLSTZ@U?e*L literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..03afa12a7b2cb1756b42fa2dcb4e78b3cdfeca90 GIT binary patch literal 894 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 _X}QEx%TW`Ef>q=eE? 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૕mya}XAM#^g0Y|`D`eg!LZ^i^L z5!lSdCc%2)WB|a%V0UTngf8*5tuCSuZdy^Tai*3`B94Rjq)M2lm?!Mi?Q@COOgeh` z3^lwCG?Sj8Z*}eN4FnsbUz-Y71|bCp+wuiF&%fGxOwgQk*ON^mQB$UM8hp0m9Y5nQ zN18618T(+our4zV_Kg^&iCaMy#z4vH#b~zOBjodjX;z%Z5m|3NK{0$gvH4RRP8+IL zf;|&Pl6yKXzmtw-G}QWhmlCdH&gmSOBGi#jo<|(hl^1mp3G0!OLm0_95}Vij3(u_V z-d(M)IcySLwB1#fg2%ErCymNf8tj=Ocn13z>D7;(_I4Kn4EKv<75C!uTR9%T^Aqz7 z#C?4$xj(!s$rUjrFHwfq4|%KrlM9V>|CKA($uJ!R2gEw6R3GRKX*Oh1^%0sXl00Be zu+SHpAu(Y!=(>D=?UjP1`pxh|@kRd(An`z2xdx@!r#=Hks2XY3blEPlVA`rYghC9z zbCfNCSdO2)=;^rwBV4y$Mmz}#wD4F5d8|dNdf+P7!tIHCHqY{y<;Rf>MdC8!WTwi& zp#kp55n$c0_8Hrn|2Q$<6?bAYx}K5br^T!Mz8F1PN<(s&V~d?4llE8|FCzia{k}DZ z^${y7coJszLdL$pZO_ess!t4-D9holjC^(5A)il-!X5}3P#1si$o<`_$S8-`YUoOs zRc|V%&?-r`0W-A`d+~d*Qf%?NGueL33r4Z!76pXya+1;eL&j@zxmV96x$PHepVa0A zDTyj4X?Hbc(U8zxNji0d@$lke?Tv1I0wsc$R0CD^V+y&$f(OQV>#ef=WD_QY(>@npqf&|{>TNIt`&5zeG9`@Ee&)SyXZA32Md1T z_fnQ6d(oq5IMjIY9tedWS`0`vwfma0Z1o>A<|r<-tGV;ydzvB%(W|ke^K*@(|`g~X9io; zB#G_8>QdM)grh^mbPAumTkm9Qz`fU-x=3T2^{Dt~pk6S^!~4jW;N5bTTeUkC`NB?( zvX%F#XvU&8=f2h#0>6*y@Uz5uoxEc$b6h-0!}5tG<&$K-L?RVm$h>hLvDPiD-94YW z_l_Uz&tV{RdlPF27s1dUx*?A*dxpqDW;O@;IwYKqXM-rf9OhC5|nk&0@P!V?g8Roe_ny zlcnWx;+o!Z&HKW!f?}BqAY^%u&(i}B(JuOtiX9i@>Y6B`L-phz=LYk7dJmLRY(KX) zdPu3OK)22a2Ob~g0ru&JNXw*FFKGC35`xAX67`)rKpa~Q@R3-HpR?cYwa5qJ$lbAJ zTD<5xg)8J%--+%MMGI>Lg5c`N?4(7((-~>|P=wmY-uv0UI0OO`>*cgFTU3w>?wHL1NqZia|23xWHYBB6NHmS;Pu#UrSeoa=a|@iV!o7MhsI?t#0Y1RcYqso1BaBnuJU?n+z=NeTNE;Za`0+BMex@W=dE!V;Mf+u
+

OpenCodex

+

Connecting to the OpenCodex proxy…

+ +
+ + + diff --git a/desktop/ui/main.js b/desktop/ui/main.js new file mode 100644 index 00000000000..85c4ea87326 --- /dev/null +++ b/desktop/ui/main.js @@ -0,0 +1,34 @@ +const params = new URLSearchParams(window.location.search); +const port = Number(params.get("port") || "10100"); +const origin = `http://127.0.0.1:${port}`; +const dashboardUrl = `${origin}/#/usage`; +const status = document.querySelector("#status"); +const retry = document.querySelector("#retry"); +let checking = false; + +async function check() { + if (checking) return; + checking = true; + status.textContent = `Connecting to OpenCodex proxy at 127.0.0.1:${port}…`; + retry.disabled = true; + try { + const response = await fetch(`${origin}/healthz`, { + cache: "no-store", + }); + if (response.ok) { + status.textContent = "Proxy is ready. Loading dashboard…"; + window.location.replace(dashboardUrl); + return; + } + throw new Error(`HTTP ${response.status}`); + } catch { + status.textContent = "The proxy is not reachable yet."; + } finally { + checking = false; + retry.disabled = false; + } +} + +retry.addEventListener("click", check); +check(); +setInterval(check, 1500); diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 29a70bd9191..2b68b5d457b 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -97,6 +97,7 @@ export default defineConfig({ { label: "Codex Prompt Layers", translations: { fr: "Couches d'invite Codex", ko: "Codex 프롬프트 레이어", "zh-CN": "Codex 提示词层", "zh-TW": "Codex 提示詞層", ru: "Слои промпта Codex", ja: "Codex プロンプトレイヤー", tr: "Codex İstem Katmanları" }, slug: "guides/codex-prompt" }, { label: "Native Context Compatibility", translations: { ko: "네이티브 컨텍스트 호환성" }, slug: "guides/codex-native-context" }, { label: "macOS Menu Bar App", translations: { fr: "Application barre de menus macOS", ko: "macOS 메뉴바 앱", "zh-CN": "macOS 菜单栏应用", "zh-TW": "macOS 選單列 App", ru: "Приложение в строке меню macOS", ja: "macOS メニューバーアプリ", tr: "macOS Menü Çubuğu Uygulaması" }, slug: "guides/macos-menu-bar" }, + { label: "Desktop App", translations: { fr: "Application de bureau", ko: "데스크톱 앱", "zh-CN": "桌面应用", "zh-TW": "桌面 App", ru: "Настольное приложение", ja: "デスクトップアプリ", tr: "Masaüstü Uygulaması" }, slug: "guides/desktop-app" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, { label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" }, 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 c134193f422..f59ebe9916b 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -303,6 +303,7 @@ Utilisez `ocx service` pour maintenir un proxy d’arrière-plan toujours actif, ### `ocx tray [--json] [--no-start]` Installe et contrôle l’icône OpenCodex dans la zone de notification Windows. Elle démarre à l’ouverture de session et fournit des commandes du proxy accessibles en un clic. `start` et `stop` contrôlent uniquement l’icône ; utilisez son menu pour contrôler le proxy. `--no-start` s’applique à `install` et installe l’icône sans la lancer immédiatement. +Obsolète : l’application OpenCodex fournit la zone de notification sous Windows, macOS et Linux ; `ocx tray` reste disponible pour les installations sans l’application de bureau. ## Tableau de bord diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index c5a7d36578e..9ff7e5a72c6 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -49,6 +49,19 @@ ocx --version opencodex --version ``` +## Standalone binary (no npm) + +Release downloads also include a standalone `ocx` binary for supported macOS, Linux, and Windows +targets. It includes the Bun runtime and dashboard, so npm, Node, and a separate Bun installation +are not required. Download the archive for your platform, extract it, and run: + +```bash +./ocx --version +./ocx start +``` + +The extracted `gui/dist` directory must stay beside the binary so `GET /` can serve the dashboard. + ### Release channels The stable `latest` channel already includes GPT-5.6 Sol/Terra/Luna catalog support for ChatGPT, diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 042eb183045..7734c9f2761 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Configure your first provider and route OpenAI Codex through openco This guide takes you from a fresh install to running Codex against a non-OpenAI model. +## Standalone binary (no npm) + +You can also use a release archive containing the `ocx` binary and Bun runtime without npm. +Extract it with its `gui/dist` directory beside the binary, then run `./ocx start`. + ## 1. Run the setup wizard ```bash diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md new file mode 100644 index 00000000000..c1405fc0c29 --- /dev/null +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -0,0 +1,84 @@ +--- +title: Desktop App +description: Install and use the OpenCodex desktop app on macOS, Windows, and Linux. +--- + +The OpenCodex desktop app combines a native tray with the web dashboard. It discovers an +existing local proxy, or starts the bundled `ocx` sidecar when no proxy is running. + +The dashboard remains available at [http://127.0.0.1:10100](http://127.0.0.1:10100). +The desktop app does not replace the proxy; it is a local shell around the dashboard and +its bundled runtime. + +## Install + +### macOS + +Download `OpenCodex--macos.dmg` from the +[latest release](https://github.com/lidge-jun/opencodex/releases). Open the DMG and drag +`OpenCodex.app` to Applications. + +On first launch, macOS Gatekeeper may warn that the developer cannot be verified. Right-click +the app, choose **Open**, and confirm **Open**. This build is signed for integrity but is not +yet notarized. + +### Windows + +Download `OpenCodex--windows-x64.msi` and run the installer. Windows SmartScreen may +warn because the installer is not yet code-signed; choose **More info → Run anyway** after +confirming that you downloaded it from the release page. + +### Linux + +Download `OpenCodex--linux-x86_64.AppImage` or +`OpenCodex--linux-amd64.deb` from the release page. + +For the AppImage: + +```bash +chmod +x OpenCodex--linux-x86_64.AppImage +./OpenCodex--linux-x86_64.AppImage +``` + +For Debian-based distributions: + +```bash +sudo apt install ./OpenCodex--linux-amd64.deb +``` + +The tray icon requires an AppIndicator-capable desktop environment. + +## First launch + +The app first looks for an existing `ocx` proxy on loopback, using the runtime port +metadata when available and falling back to port `10100`. If no proxy answers, it starts +the bundled sidecar. The dashboard is then opened inside the app's webview. + +Use the tray's **Open dashboard** or **Open in browser** action to move between the +embedded dashboard and your normal browser. The tray also provides update checks. + +## Updates + +Choose **Check for Updates…** in the tray menu to check immediately. Release builds also +check automatically after startup and every six hours. Updates are verified with the +project's signed updater public key before installation. On macOS, in-app updates download +`OpenCodex--macos.app.tar.gz`; the DMG is for the first installation. +The release manifest is generated only when the updater key secret is configured and then +requires all four platforms to be signed. + +## Widget + +The macOS app includes the OpenCodex WidgetKit extension. See the +[macOS Menu Bar App guide](/opencodex/guides/macos-menu-bar/) for widget setup and the +privacy-safe snapshot details. + +## Uninstall + +On macOS, drag `OpenCodex.app` from Applications to the Trash. On Windows, remove +OpenCodex from **Installed apps**. On Debian-based Linux systems, run: + +```bash +sudo apt remove opencodex +``` + +For an AppImage, delete the downloaded file. diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 209efa75664..c95888a716e 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -9,16 +9,24 @@ per-provider quota pressure, without opening the dashboard. It is a separate application from the proxy. `ocx` keeps running as it always has; the companion is a read-mostly client that talks to the local management API. -## Install +## Desktop app (Tauri) + +The same dashboard can run inside the OpenCodex desktop app. The Usage companion panel +uses the OS selector to show the matching macOS, Windows, or Linux installation steps. +While the dashboard is inside the desktop shell, choose **Open in browser** to open the +current dashboard view in your normal browser. -Download `OpenCodex--macos-universal.zip` from the -[latest release](https://github.com/lidge-jun/opencodex/releases), unzip it, and move -`OpenCodex.app` to your Applications folder. +## Install -Verify the download if you like — every release ships a checksum beside it: +Install the desktop app from the +[latest release](https://github.com/lidge-jun/opencodex/releases). On macOS, download +`OpenCodex--macos.dmg`, open it, and drag `OpenCodex.app` to Applications. +Windows users can run `OpenCodex--windows-x64.msi`; Linux users can use the +AppImage or `OpenCodex--linux-amd64.deb`. ```bash -shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb ``` ## First launch: Gatekeeper @@ -104,7 +112,7 @@ Everything else — accounts, model configuration, storage — stays in the dash Add the widget from the desktop: right-click, choose **Edit Widgets**, then add **OpenCodex**. It shows proxy status, today's usage, quota pressure, and the same -privacy-safe usage snapshot as the menu bar app. The widget refreshes when the app polls. +privacy-safe usage snapshot as the desktop app. The widget refreshes when the app polls. It requires macOS 14 or later and reads only the privacy-safe snapshot written by the OpenCodex app; it does not receive API keys or raw account data. @@ -138,11 +146,13 @@ Requires macOS 13 or later, the Xcode Command Line Tools, and [Bun](https://bun. ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -The bundle appears at `dist/macos/OpenCodex.app`. Without Bun you can run the script -directly: `bash scripts/build-macos-app.sh`. +The bundle appears in Tauri's release output, with the WidgetKit appex under +`OpenCodex.app/Contents/PlugIns/`. Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command Line Tools ships only current-architecture Swift compatibility libraries, and the build @@ -152,7 +162,7 @@ If you have a Developer ID certificate in your keychain, set `MACOS_SIGN_IDENTIT sign with the hardened runtime instead of ad-hoc: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## Uninstall diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md index a62daede0a6..49b50f7d37d 100644 --- a/docs-site/src/content/docs/ja/getting-started/installation.md +++ b/docs-site/src/content/docs/ja/getting-started/installation.md @@ -43,6 +43,19 @@ ocx --version opencodex --version ``` +## スタンドアロンバイナリ(npm 不要) + +リリースには、対応する macOS、Linux、Windows 向けのスタンドアロン `ocx` バイナリも含まれます。 +Bun ランタイムとダッシュボードが含まれるため、npm、Node、別途の Bun インストールは必要ありません。 +お使いの環境向けのアーカイブをダウンロードして展開し、次のように実行します。 + +```bash +./ocx --version +./ocx start +``` + +ダッシュボードを提供するため、展開した `gui/dist` ディレクトリはバイナリの隣に置いたままにしてください。 + ### 配布チャネル 安定チャネルの `latest` にも ChatGPT、OpenAI API キー、OpenRouter、実験段階の Cursor 経路のための diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index f9184dfc5f6..9c2d25a93e4 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 最初のプロバイダーを構成し、3 つのコマンドで O このガイドでは、新規インストールから非 OpenAI モデルに対して Codex を実行するまでを説明します。 +## スタンドアロンバイナリ(npm 不要) + +npm を使わず、Bun ランタイムを含むリリースアーカイブの `ocx` バイナリも利用できます。 +`gui/dist` ディレクトリをバイナリの隣に置いて展開し、`./ocx start` を実行してください。 + ## 1. セットアップウィザードを実行します ```bash diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md index ff67a25ccc7..bf429ac0dc1 100644 --- a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -9,18 +9,28 @@ description: OpenCodex プロキシの状態、使用量、プロバイダーの プロキシとは別のアプリケーションです。`ocx` はこれまで通り動作し、メニューバーアプリは ローカルの管理 API に接続するクライアントとして動きます。 -## インストール +## デスクトップアプリ (Tauri) + +同じダッシュボードを OpenCodex デスクトップアプリ内で実行できます。Usage コンパニオン +パネルは OS に合ったインストール手順を表示し、デスクトップシェル内では **ブラウザーで開く** +を選ぶと現在の画面を通常のブラウザーで開けます。 -[リリースページ](https://github.com/lidge-jun/opencodex/releases)から -`OpenCodex--macos-universal.zip` をダウンロードし、展開して `OpenCodex.app` を -アプリケーションフォルダに移動します。 +## インストール -ダウンロードを検証する場合、リリースごとにチェックサムが添付されています。 +基本のインストール方法は OpenCodex デスクトップアプリです。[リリースページ](https://github.com/lidge-jun/opencodex/releases)から、macOS では +`OpenCodex--macos.dmg` をダウンロードし、DMG を開いて `OpenCodex.app` を +アプリケーションフォルダへドラッグします。Windows では +`OpenCodex--windows-x64.msi` を実行し、Linux では AppImage または +`OpenCodex--linux-amd64.deb` を使います。 ```bash -shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb ``` +Windows SmartScreen や macOS Gatekeeper の警告が表示されることがあります。アプリは +既存の `ocx` に接続し、見つからなければ同梱のサイドカーを起動します。 + ## 初回起動: Gatekeeper **初回起動はブロックされます。** 次のメッセージが表示されます。 @@ -134,11 +144,13 @@ macOS 13 以降、Xcode Command Line Tools、および [Bun](https://bun.sh) が ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -バンドルは `dist/macos/OpenCodex.app` に生成されます。Bun がない場合はスクリプトを直接 -実行できます: `bash scripts/build-macos-app.sh`。 +バンドルは Tauri のリリース出力に生成され、WidgetKit 拡張は +`OpenCodex.app/Contents/PlugIns/` に含まれます。 ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には 現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは @@ -148,7 +160,7 @@ bun run build:macos ではなく hardened runtime で署名できます。 ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## アンインストール 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 bf37bc911b6..877954e9755 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -272,6 +272,7 @@ OpenCodex の更新後、既存の Windows シムにこの動作を適用する ### `ocx tray [--json] [--no-start]` Windows ステータス トレイ アイコンをインストールして制御します。 Windows ログイン時に開始され、ワンクリックでプロキシ コントロールを提供します。 `start` および `stop` はアイコンのみを制御します。そのメニューを使用してプロキシを制御します。 `--no-start` は `install` に適用され、トレイをすぐに起動せずにインストールします。 +非推奨: OpenCodex デスクトップアプリは Windows、macOS、Linux のトレイを提供します。`ocx tray` はデスクトップアプリを使わないインストール向けに残っています。 ## ダッシュボード diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 70a62f86099..146f25bb01c 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -43,6 +43,19 @@ ocx --version opencodex --version ``` +## 독립 실행형 바이너리(npm 없음) + +릴리스에는 지원되는 macOS, Linux, Windows용 독립 실행형 `ocx` 바이너리도 포함됩니다. +Bun 런타임과 대시보드가 포함되어 있으므로 npm, Node 또는 별도의 Bun 설치가 필요하지 않습니다. +플랫폼에 맞는 아카이브를 다운로드해 압축을 풀고 다음과 같이 실행하세요. + +```bash +./ocx --version +./ocx start +``` + +대시보드를 제공하려면 압축을 푼 `gui/dist` 디렉터리를 바이너리 옆에 그대로 두어야 합니다. + ### 배포 채널 안정화 채널인 `latest`에도 ChatGPT, OpenAI API 키, OpenRouter, 실험 단계의 Cursor 경로를 위한 diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index f1a179649b7..7e92511d56a 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 첫 프로바이더를 설정하고 명령어 세 개로 OpenAI Cod 이 가이드는 새로 설치한 상태에서 OpenAI가 아닌 모델로 Codex를 실행하기까지의 과정을 안내합니다. +## 독립 실행형 바이너리(npm 없음) + +npm 없이 Bun 런타임이 포함된 릴리스 아카이브의 `ocx` 바이너리를 사용할 수도 있습니다. +`gui/dist` 디렉터리를 바이너리 옆에 둔 채 압축을 풀고 `./ocx start`를 실행하세요. + ## 1. 설정 마법사 실행 ```bash diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md index 53159b4589b..6aeb3e77425 100644 --- a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -9,18 +9,27 @@ description: OpenCodex 프록시 상태와 사용량, 프로바이더 쿼터를 프록시와는 별개의 앱입니다. `ocx`는 지금까지처럼 그대로 돌아가고, 메뉴바 앱은 로컬 관리 API에 붙는 클라이언트입니다. -## 설치 +## 데스크톱 앱 (Tauri) + +같은 대시보드를 OpenCodex 데스크톱 앱에서 실행할 수 있습니다. 사용량 패널은 운영체제에 +맞는 설치 단계를 보여주며, 데스크톱 셸 안에서는 **브라우저에서 열기**를 선택해 현재 +대시보드 화면을 일반 브라우저로 열 수 있습니다. -[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 -`OpenCodex-<버전>-macos-universal.zip`을 받아 압축을 풀고 `OpenCodex.app`을 응용 -프로그램 폴더로 옮기세요. +## 설치 -받은 파일을 검증하고 싶다면 릴리스마다 체크섬이 함께 올라갑니다. +기본 설치 경로는 OpenCodex 데스크톱 앱입니다. [릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 macOS용 +`OpenCodex-<버전>-macos.dmg`를 내려받아 DMG를 열고 `OpenCodex.app`을 응용 프로그램 +폴더로 드래그하세요. Windows에서는 `OpenCodex-<버전>-windows-x64.msi`를 실행하고, +Linux에서는 AppImage 또는 `OpenCodex-<버전>-linux-amd64.deb`를 사용하세요. ```bash -shasum -a 256 -c OpenCodex-<버전>-macos-universal.zip.sha256 +chmod +x OpenCodex-<버전>-linux-x86_64.AppImage +sudo apt install ./OpenCodex-<버전>-linux-amd64.deb ``` +Windows SmartScreen 또는 macOS Gatekeeper 경고가 표시될 수 있습니다. 앱은 기존 `ocx` +프록시에 연결하고, 찾지 못하면 포함된 사이드카를 시작합니다. + ## 첫 실행: Gatekeeper 차단 **처음 실행하면 macOS가 막습니다.** 이런 메시지가 뜹니다. @@ -130,11 +139,13 @@ macOS 13 이상, Xcode Command Line Tools, 그리고 [Bun](https://bun.sh)이 ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -번들은 `dist/macos/OpenCodex.app`에 생깁니다. Bun 없이 쓰려면 스크립트를 직접 실행하세요: -`bash scripts/build-macos-app.sh`. +번들은 Tauri 릴리스 출력에 생성되며, WidgetKit 확장은 +`OpenCodex.app/Contents/PlugIns/` 아래에 포함됩니다. 유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools 에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를 @@ -144,7 +155,7 @@ bun run build:macos 런타임으로 서명할 수 있습니다. ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## 삭제 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 e4f2913c6f6..97048c3768e 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -390,6 +390,8 @@ OpenCodex를 업데이트한 뒤 기존 Windows shim에 이 동작을 적용하 Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로그인 시 시작되며, 프록시를 원클릭으로 제어할 수 있습니다. `start`와 `stop`은 아이콘만 제어합니다. 프록시 제어는 메뉴를 사용하세요. `--no-start`는 `install`에 적용되며, 트레이를 바로 실행하지 않고 설치합니다. +지원 중단 예정: OpenCodex 데스크톱 앱이 Windows, macOS, Linux에서 트레이를 제공합니다. +`ocx tray`는 데스크톱 앱이 없는 설치를 위해 계속 사용할 수 있습니다. ## 대시보드 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e8380c73ca6..07f6ca58537 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -611,6 +611,8 @@ file is not part of the injected `env_key` contract; the launching process must Install and control the Windows status tray icon. It starts at Windows login and provides one-click proxy controls. `start` and `stop` control the icon only; use its menu to control the proxy. `--no-start` applies to `install` and installs the tray without launching it immediately. +Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` +remains for installs without the desktop app. ## Dashboard diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md index a1f3724a4ba..ca0fe20b3e4 100644 --- a/docs-site/src/content/docs/ru/getting-started/installation.md +++ b/docs-site/src/content/docs/ru/getting-started/installation.md @@ -45,6 +45,19 @@ ocx --version opencodex --version ``` +## Автономный бинарный файл (без npm) + +В релиз входят автономные бинарные файлы `ocx` для поддерживаемых macOS, Linux и Windows. +Они содержат рантайм Bun и дашборд, поэтому npm, Node и отдельная установка Bun не нужны. +Скачайте архив для своей платформы, распакуйте его и выполните: + +```bash +./ocx --version +./ocx start +``` + +Чтобы дашборд был доступен, оставьте распакованный каталог `gui/dist` рядом с бинарным файлом. + ### Каналы релизов Стабильный канал `latest` уже включает поддержку каталога GPT-5.6 Sol/Terra/Luna для маршрутов diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 1b086c7db2c..088843f0f96 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Настройте первого провайдера и напр Это руководство проводит от чистой установки до запуска Codex с моделью не от OpenAI. +## Автономный бинарный файл (без npm) + +Можно также использовать архив с бинарным файлом `ocx` и рантаймом Bun без npm. +Распакуйте его, оставив каталог `gui/dist` рядом с бинарным файлом, и выполните `./ocx start`. + ## 1. Запустите мастер настройки ```bash diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md index 30f54ad9835..1d264a93c73 100644 --- a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -9,18 +9,28 @@ description: Нативное приложение, показывающее с Это отдельная программа. `ocx` работает как раньше, а приложение в строке меню — клиент, который обращается к локальному management API. -## Установка +## Настольное приложение (Tauri) + +Ту же панель можно открыть в настольном приложении OpenCodex. Панель компаньона показывает +шаги установки для выбранной ОС, а пункт **Открыть в браузере** открывает текущий экран +в обычном браузере, когда панель работает внутри desktop shell. -Скачайте `OpenCodex-<версия>-macos-universal.zip` со -[страницы релизов](https://github.com/lidge-jun/opencodex/releases), распакуйте и -переместите `OpenCodex.app` в папку «Программы». +## Установка -Если хотите проверить загрузку, к каждому релизу прилагается контрольная сумма: +Основной способ установки — настольное приложение OpenCodex. На +[странице релизов](https://github.com/lidge-jun/opencodex/releases) скачайте для macOS +`OpenCodex-<версия>-macos.dmg`, откройте DMG и перетащите `OpenCodex.app` в «Программы». +В Windows запустите `OpenCodex-<версия>-windows-x64.msi`, а в Linux используйте AppImage +или `OpenCodex-<версия>-linux-amd64.deb`. ```bash -shasum -a 256 -c OpenCodex-<версия>-macos-universal.zip.sha256 +chmod +x OpenCodex-<версия>-linux-x86_64.AppImage +sudo apt install ./OpenCodex-<версия>-linux-amd64.deb ``` +Windows SmartScreen и macOS Gatekeeper могут показать предупреждение. Приложение подключается +к существующему `ocx`, а если его нет — запускает встроенный sidecar. + ## Первый запуск: Gatekeeper **Первый запуск будет заблокирован.** macOS покажет: @@ -136,11 +146,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -Бандл появится в `dist/macos/OpenCodex.app`. Без Bun скрипт можно запустить напрямую: -`bash scripts/build-macos-app.sh`. +Бандл появится в выходных файлах Tauri, а расширение WidgetKit будет включено в +`OpenCodex.app/Contents/PlugIns/`. Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом @@ -150,7 +162,7 @@ bun run build:macos подписать с hardened runtime вместо ad-hoc: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## Удаление 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 f338d75388f..6719e9cf2cb 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -395,6 +395,8 @@ ocx codex-shim uninstall one-click управление прокси. `start` и `stop` управляют только иконкой; самим прокси нужно управлять из её меню. `--no-start` применяется к `install` и устанавливает tray, не запуская её немедленно. +Устарело: приложение OpenCodex для рабочего стола предоставляет трей в Windows, macOS и Linux; +`ocx tray` остаётся для установок без приложения для рабочего стола. ## Дашборд 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 ffa5b19df50..f1c30392609 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -470,6 +470,8 @@ Windows durum tepsisi simgesini kurun ve kontrol edin. Windows oturum açılış başlar ve tek tıklamayla proxy kontrolleri sağlar. `start` ve `stop` yalnızca simgeyi kontrol eder; proxy'yi kontrol etmek için menüsünü kullanın. `--no-start`, `install` için geçerlidir ve tepsiyi hemen başlatmadan kurar. +Kullanımdan kaldırıldı: OpenCodex masaüstü uygulaması Windows, macOS ve Linux'ta tepsi sağlar; +`ocx tray`, masaüstü uygulaması olmayan kurulumlar için kullanılmaya devam eder. ## Kontrol Paneli diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md index eb5b02ec948..335debd845d 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md @@ -42,6 +42,18 @@ ocx --version opencodex --version ``` +## 独立二进制文件(无需 npm) + +发布包还包含适用于 macOS、Linux 和 Windows 的独立 `ocx` 二进制文件。 +它内置 Bun 运行时和仪表盘,因此无需安装 npm、Node 或单独的 Bun。下载适合你平台的压缩包,解压后运行: + +```bash +./ocx --version +./ocx start +``` + +为了让仪表盘可用,请将解压后的 `gui/dist` 目录保留在二进制文件旁边。 + ### 发布渠道 稳定的 `latest` 渠道已经包含 ChatGPT、OpenAI API key、OpenRouter 以及实验性 Cursor 路由所需的 diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index e965b159a6b..91ac4ba047b 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 配置你的第一个 provider,并在三条命令内让 OpenAI Co 本指南将带你从全新安装,一路走到用一个非 OpenAI 模型运行 Codex。 +## 独立二进制文件(无需 npm) + +你也可以使用包含 Bun 运行时的发布压缩包中的 `ocx`,无需 npm。 +解压时将 `gui/dist` 目录保留在二进制文件旁边,然后运行 `./ocx start`。 + ## 1. 运行设置向导 ```bash diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md index 50058524bf0..2f3dc02616e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -7,17 +7,26 @@ description: 在菜单栏中查看 OpenCodex 代理状态、用量和各提供 它与代理是两个独立的程序。`ocx` 照常运行,菜单栏应用只是连接本地管理 API 的客户端。 -## 安装 +## 桌面应用(Tauri) + +同一个仪表板也可以在 OpenCodex 桌面应用中运行。用量面板会显示匹配操作系统的安装步骤; +在桌面壳中选择**在浏览器中打开**,即可在普通浏览器中打开当前页面。 -从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 -`OpenCodex--macos-universal.zip`,解压后把 `OpenCodex.app` 移到「应用程序」文件夹。 +## 安装 -如果需要校验下载文件,每个版本都附带校验和: +推荐使用 OpenCodex 桌面应用安装。从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 macOS 的 +`OpenCodex--macos.dmg`,打开 DMG 后将 `OpenCodex.app` 拖到「应用程序」文件夹。 +Windows 运行 `OpenCodex--windows-x64.msi`,Linux 使用 AppImage 或 +`OpenCodex--linux-amd64.deb`。 ```bash -shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb ``` +Windows SmartScreen 或 macOS Gatekeeper 可能显示警告。应用会连接现有的 `ocx` 代理; +找不到代理时则启动内置 sidecar。 + ## 首次启动:Gatekeeper **首次启动会被阻止。** macOS 会提示: @@ -117,11 +126,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -程序包会生成在 `dist/macos/OpenCodex.app`。若没有 Bun,可以直接运行脚本: -`bash scripts/build-macos-app.sh`。 +程序包会生成在 Tauri 的发布输出中,WidgetKit 扩展位于 +`OpenCodex.app/Contents/PlugIns/`。 构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift 兼容库,此时构建会给出说明信息,而不是抛出链接器错误。 @@ -130,7 +141,7 @@ bun run build:macos 替代 ad-hoc 签名: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## 卸载 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 30687e2b81e..b1fdef059a3 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 @@ -259,6 +259,7 @@ ocx codex-shim uninstall ### `ocx tray [--json] [--no-start]` 安装并控制 Windows 状态托盘图标。它会在 Windows 登录时启动,并提供一键代理控制。`start` 和 `stop` 只控制图标本身;要控制代理,请使用其菜单。`--no-start` 适用于 `install`,会安装托盘但不会立即启动。 +已弃用:OpenCodex 桌面应用在 Windows、macOS 和 Linux 上提供托盘;没有桌面应用的安装仍可使用 `ocx tray`。 ## 仪表盘 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 d9c104d3c91..4317ef37b30 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 @@ -244,6 +244,7 @@ ocx codex-shim uninstall ### `ocx tray [--json] [--no-start]` 安裝並控制 Windows 狀態列圖示。它在 Windows 登入時啟動並提供一鍵代理控制。`start` 與 `stop` 僅控制圖示;請用其選單控制代理。`--no-start` 適用於 `install`,並在不立即啟動它的情況下安裝 tray。 +已淘汰:OpenCodex 桌面應用程式在 Windows、macOS 與 Linux 提供系統匣;沒有桌面應用程式的安裝仍可使用 `ocx tray`。 ## 儀表板 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index b7ec8f9db04..c53975f682a 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -24,6 +24,7 @@ import { readModelsTab, type ModelsTab } from "./pages/models-tab"; import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; import { useCodexRestart } from "./use-codex-restart"; +import { isDesktopShell, isExternalLink } from "./lib/desktop-shell"; type Theme = "light" | "dark" | "system"; @@ -172,6 +173,23 @@ export default function App() { }; }, []); + useEffect(() => { + if (!isDesktopShell()) return; + const interceptExternalLinks = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + const anchor = target.closest("a[href]"); + if (!(anchor instanceof HTMLAnchorElement)) return; + const href = anchor.href; + if (!isExternalLink(href)) return; + event.preventDefault(); + // Rust denies external HTTP(S) navigation and opens it in the system browser. + window.location.assign(href); + }; + document.addEventListener("click", interceptExternalLinks, true); + return () => document.removeEventListener("click", interceptExternalLinks, true); + }, []); + useEffect(() => { const el = document.documentElement; if (theme === "system") { el.removeAttribute("data-theme"); localStorage.removeItem(THEME_KEY); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0ed0d696111..c09ccbbb5e5 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -983,7 +983,7 @@ export const de: Record = { "usage.section.coverage": "Abdeckungs-Aufschlüsselung", "usage.section.companion": "Menüleiste & Widget", "usage.companion.title": "Menüleiste & Widget", - "usage.companion.description": "Diese Einstellungen steuern die OpenCodex-Menüleisten-App und ihr Widget.", + "usage.companion.description": "Diese Einstellungen steuern die OpenCodex Desktop-/Menüleisten-App und ihr Widget.", "usage.companion.installGuide": "Installationsanleitung", "usage.companion.loading": "Zeitachse wird geladen…", "usage.companion.timelineUnavailable": "Zeitachse nicht verfügbar", @@ -994,13 +994,26 @@ export const de: Record = { "usage.companion.corrupt": "Die Datei mit den Begleiteinstellungen ist beschädigt. Die Steuerelemente zeigen Standardwerte; das Speichern ist pausiert, bis Sie die Datei ersetzen.", "usage.companion.corruptReset": "Durch Standardwerte ersetzen", "usage.companion.connected": "Menüleisten-App verbunden · {age}", - "usage.companion.installTitle": "Menüleisten-App installieren", - "usage.companion.installStep1": "Laden Sie OpenCodex--macos-universal.zip aus der neuesten Veröffentlichung herunter und ziehen Sie OpenCodex.app in Programme.", - "usage.companion.installStep2": "Erster Start: Klicken Sie mit der rechten Maustaste auf OpenCodex.app → Öffnen (die App ist nur ad-hoc signiert, daher fragt Gatekeeper einmal).", - "usage.companion.installStep3": "Die App findet diesen Proxy selbst; das Widget erscheint in der Widget-Galerie, sobald die App ausgeführt wurde.", + "usage.companion.connectedDesktop": "Desktop-App verbunden · {age}", + "usage.companion.installTitle": "Desktop-App installieren", + "usage.companion.installOs": "Betriebssystem", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "Du bist in der OpenCodex-Desktop-App {version}", + "usage.companion.openInBrowser": "Im Browser öffnen", + "usage.companion.installMacStep1": "Laden Sie OpenCodex--macos.dmg aus der neuesten Veröffentlichung herunter und ziehen Sie OpenCodex.app in Programme.", + "usage.companion.installMacStep2": "Erster Start: Klicken Sie mit der rechten Maustaste auf OpenCodex.app → Öffnen (Gatekeeper fragt einmal, bis die App notariell signiert ist).", + "usage.companion.installMacStep3": "Die App findet diesen Proxy selbst; das Widget erscheint in der Widget-Galerie, sobald die App ausgeführt wurde.", + "usage.companion.installWinStep1": "Laden Sie OpenCodex--windows-x64.msi aus der neuesten Veröffentlichung herunter und führen Sie es aus.", + "usage.companion.installWinStep2": "Wenn SmartScreen warnt, wählen Sie Weitere Informationen → Trotzdem ausführen (der Installer ist noch nicht signiert).", + "usage.companion.installWinStep3": "OpenCodex erscheint in der Taskleiste und verbindet sich mit diesem Proxy oder startet den gebündelten Proxy.", + "usage.companion.installLinuxStep1": "Laden Sie OpenCodex--linux-x86_64.AppImage (oder die .deb-Datei) aus der neuesten Veröffentlichung herunter.", + "usage.companion.installLinuxStep2": "Führen Sie chmod +x für das AppImage aus und starten Sie es; ein Tray-Symbol benötigt einen AppIndicator-fähigen Desktop.", + "usage.companion.installLinuxStep3": "Die App verbindet sich mit diesem Proxy oder startet den gebündelten Proxy.", "usage.companion.notConnected": "Noch keine Menüleisten-App hat sich mit diesem Proxy verbunden.", "usage.companion.lastSeen": "Zuletzt gesehen {age}", - "usage.companion.installAnother": "Auf einem anderen Mac installieren", + "usage.companion.installAnother": "Auf einem anderen Gerät installieren", "usage.companion.saved": "Gespeichert · {time}", "usage.companion.saveFailed": "Speichern fehlgeschlagen: {error}", "usage.companion.reset": "Auf Standardwerte zurücksetzen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 29202551e70..2c5b9f92a01 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1036,7 +1036,7 @@ export const en = { "usage.section.coverage": "Coverage breakdown", "usage.section.companion": "Menu bar & widget", "usage.companion.title": "Menu bar & widget", - "usage.companion.description": "Settings here drive the OpenCodex menu bar app and its widget.", + "usage.companion.description": "Settings here drive the OpenCodex desktop/menu bar app and its widget.", "usage.companion.installGuide": "Install guide", "usage.companion.loading": "Loading timeline…", "usage.companion.timelineUnavailable": "Timeline unavailable", @@ -1047,13 +1047,26 @@ export const en = { "usage.companion.corrupt": "The companion settings file is corrupt. Controls show defaults; saving is paused until you replace the file.", "usage.companion.corruptReset": "Replace with defaults", "usage.companion.connected": "Menu bar app connected · {age}", - "usage.companion.installTitle": "Install the menu bar app", - "usage.companion.installStep1": "Download OpenCodex--macos-universal.zip from the latest release and drag OpenCodex.app to Applications.", - "usage.companion.installStep2": "First launch: right-click OpenCodex.app → Open (the app is ad-hoc signed, so Gatekeeper asks once).", - "usage.companion.installStep3": "The app finds this proxy on its own; the widget appears in the widget gallery once the app has run.", + "usage.companion.connectedDesktop": "Desktop app connected · {age}", + "usage.companion.installTitle": "Install the desktop app", + "usage.companion.installOs": "Operating system", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "You’re in the OpenCodex desktop app {version}", + "usage.companion.openInBrowser": "Open in browser", + "usage.companion.installMacStep1": "Download OpenCodex--macos.dmg from the latest release and drag OpenCodex.app to Applications.", + "usage.companion.installMacStep2": "First launch: right-click OpenCodex.app → Open (Gatekeeper asks once until the app is notarized).", + "usage.companion.installMacStep3": "The app finds this proxy on its own; the widget appears in the widget gallery once the app has run.", + "usage.companion.installWinStep1": "Download OpenCodex--windows-x64.msi from the latest release and run it.", + "usage.companion.installWinStep2": "If SmartScreen warns, choose More info → Run anyway (the installer is not yet code-signed).", + "usage.companion.installWinStep3": "OpenCodex appears in the system tray and attaches to this proxy, or starts its bundled one.", + "usage.companion.installLinuxStep1": "Download OpenCodex--linux-x86_64.AppImage (or the .deb) from the latest release.", + "usage.companion.installLinuxStep2": "chmod +x the AppImage and run it; a tray icon requires an AppIndicator-capable desktop.", + "usage.companion.installLinuxStep3": "The app attaches to this proxy, or starts its bundled one.", "usage.companion.notConnected": "No menu bar app has connected to this proxy yet.", "usage.companion.lastSeen": "Last seen {age}", - "usage.companion.installAnother": "Install on another Mac", + "usage.companion.installAnother": "Install on another device", "usage.companion.saved": "Saved · {time}", "usage.companion.saveFailed": "Couldn’t save: {error}", "usage.companion.reset": "Reset to defaults", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 715be09c706..348914135d4 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1013,7 +1013,7 @@ export const fr: Record = { "usage.section.coverage": "Répartition de la couverture", "usage.section.companion": "Barre des menus et widget", "usage.companion.title": "Barre des menus et widget", - "usage.companion.description": "Ces réglages contrôlent l’app OpenCodex de la barre des menus et son widget.", + "usage.companion.description": "Ces réglages pilotent l’application OpenCodex de bureau/barre des menus et son widget.", "usage.companion.installGuide": "Guide d’installation", "usage.companion.loading": "Chargement de la chronologie…", "usage.companion.timelineUnavailable": "Chronologie indisponible", @@ -1064,13 +1064,26 @@ export const fr: Record = { "usage.companion.placeholders": "Paramètres substituables :", "usage.companion.modelsOnChart": "Modèles du graphique", "usage.companion.connected": "App de barre des menus connectée · {age}", - "usage.companion.installTitle": "Installer l’app de barre des menus", - "usage.companion.installStep1": "Téléchargez OpenCodex--macos-universal.zip depuis la dernière version et faites glisser OpenCodex.app dans Applications.", - "usage.companion.installStep2": "Premier lancement : faites un clic droit sur OpenCodex.app → Ouvrir (l’app est signée ad hoc, Gatekeeper ne demande donc qu’une confirmation).", - "usage.companion.installStep3": "L’app trouve ce proxy automatiquement ; le widget apparaît dans la galerie de widgets après le lancement de l’app.", + "usage.companion.connectedDesktop": "Application de bureau connectée · {age}", + "usage.companion.installTitle": "Installer l’application de bureau", + "usage.companion.installOs": "Système d’exploitation", + "usage.companion.osMac": "Mac", + "usage.companion.osWindows": "Système Windows", + "usage.companion.osLinux": "Système Linux", + "usage.companion.runningInDesktop": "Vous êtes dans l’application de bureau OpenCodex {version}", + "usage.companion.openInBrowser": "Ouvrir dans le navigateur", + "usage.companion.installMacStep1": "Téléchargez OpenCodex--macos.dmg depuis la dernière version et faites glisser OpenCodex.app dans Applications.", + "usage.companion.installMacStep2": "Premier lancement : faites un clic droit sur OpenCodex.app → Ouvrir (Gatekeeper demande une confirmation jusqu’à la notarisation).", + "usage.companion.installMacStep3": "L’application trouve ce proxy automatiquement ; le widget apparaît dans la galerie après son lancement.", + "usage.companion.installWinStep1": "Téléchargez OpenCodex--windows-x64.msi depuis la dernière version et exécutez-le.", + "usage.companion.installWinStep2": "Si SmartScreen vous avertit, choisissez Informations supplémentaires → Exécuter quand même (l’installateur n’est pas encore signé).", + "usage.companion.installWinStep3": "OpenCodex apparaît dans la zone de notification et se connecte à ce proxy, ou démarre celui fourni.", + "usage.companion.installLinuxStep1": "Téléchargez OpenCodex--linux-x86_64.AppImage (ou le .deb) depuis la dernière version.", + "usage.companion.installLinuxStep2": "Exécutez chmod +x sur l’AppImage puis lancez-le ; une icône de zone de notification nécessite un bureau compatible AppIndicator.", + "usage.companion.installLinuxStep3": "L’application se connecte à ce proxy ou démarre celui fourni.", "usage.companion.notConnected": "Aucune app de barre des menus ne s’est encore connectée à ce proxy.", "usage.companion.lastSeen": "Dernière connexion {age}", - "usage.companion.installAnother": "Installer sur un autre Mac", + "usage.companion.installAnother": "Installer sur un autre appareil", "usage.companion.modelsCount": "{selected} sur {total} dans le graphique", "usage.companion.modelsShowAll": "Tout afficher", "usage.companion.hideProviders": "Masquer les fournisseurs", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d4eef7d8033..d5920d7015e 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -948,7 +948,7 @@ export const ja: Record = { "usage.section.coverage": "カバレッジ内訳", "usage.section.companion": "メニューバーとウィジェット", "usage.companion.title": "メニューバーとウィジェット", - "usage.companion.description": "ここでの設定は OpenCodex のメニューバーアプリとウィジェットを制御します。", + "usage.companion.description": "ここでの設定は OpenCodex デスクトップ/メニューバーアプリとウィジェットを制御します。", "usage.companion.installGuide": "インストールガイド", "usage.companion.loading": "タイムラインを読み込み中…", "usage.companion.timelineUnavailable": "タイムラインを利用できません", @@ -999,13 +999,26 @@ export const ja: Record = { "usage.companion.placeholders": "プレースホルダー:", "usage.companion.modelsOnChart": "グラフのモデル", "usage.companion.connected": "メニューバーアプリ接続済み · {age}", - "usage.companion.installTitle": "メニューバーアプリをインストール", - "usage.companion.installStep1": "最新リリースから OpenCodex--macos-universal.zip をダウンロードし、OpenCodex.app をアプリケーションに移動します。", - "usage.companion.installStep2": "初回起動:OpenCodex.app を右クリックして「開く」を選択します(アドホック署名のため、Gatekeeper の確認は一度だけです)。", - "usage.companion.installStep3": "アプリはこのプロキシを自動検出します。アプリを一度起動するとウィジェットギャラリーに表示されます。", + "usage.companion.connectedDesktop": "デスクトップアプリ接続済み · {age}", + "usage.companion.installTitle": "デスクトップアプリをインストール", + "usage.companion.installOs": "オペレーティングシステム", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "OpenCodex デスクトップアプリ {version} を使用中です", + "usage.companion.openInBrowser": "ブラウザで開く", + "usage.companion.installMacStep1": "最新リリースから OpenCodex--macos.dmg をダウンロードし、OpenCodex.app をアプリケーションへ移動します。", + "usage.companion.installMacStep2": "初回起動:OpenCodex.app を右クリックして「開く」を選択します(公証されるまで Gatekeeper が一度確認します)。", + "usage.companion.installMacStep3": "アプリはこのプロキシを自動検出します。アプリを起動するとウィジェットギャラリーに表示されます。", + "usage.companion.installWinStep1": "最新リリースから OpenCodex--windows-x64.msi をダウンロードして実行します。", + "usage.companion.installWinStep2": "SmartScreen が警告したら「詳細情報」→「実行」を選択します(インストーラーはまだコード署名されていません)。", + "usage.companion.installWinStep3": "OpenCodex はシステムトレイに表示され、このプロキシに接続するか、同梱のプロキシを起動します。", + "usage.companion.installLinuxStep1": "最新リリースから OpenCodex--linux-x86_64.AppImage(または .deb)をダウンロードします。", + "usage.companion.installLinuxStep2": "AppImage に chmod +x を実行して起動します。トレイアイコンには AppIndicator 対応デスクトップが必要です。", + "usage.companion.installLinuxStep3": "アプリはこのプロキシに接続するか、同梱のプロキシを起動します。", "usage.companion.notConnected": "このプロキシに接続したメニューバーアプリはまだありません。", "usage.companion.lastSeen": "最終接続 {age}", - "usage.companion.installAnother": "別の Mac にインストール", + "usage.companion.installAnother": "別のデバイスにインストール", "usage.companion.modelsCount": "{selected} / {total} がグラフに表示中", "usage.companion.modelsShowAll": "すべて表示", "usage.companion.hideProviders": "プロバイダーを非表示", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b2e8b94c500..6913c903170 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1017,7 +1017,7 @@ export const ko: Record = { "usage.section.coverage": "커버리지 상세", "usage.section.companion": "메뉴 막대 및 위젯", "usage.companion.title": "메뉴 막대 및 위젯", - "usage.companion.description": "여기 설정은 OpenCodex 메뉴 막대 앱과 위젯을 제어합니다.", + "usage.companion.description": "이 설정은 OpenCodex 데스크톱/메뉴 막대 앱과 위젯을 제어합니다.", "usage.companion.installGuide": "설치 안내", "usage.companion.loading": "타임라인 로드 중…", "usage.companion.timelineUnavailable": "타임라인을 사용할 수 없습니다", @@ -1068,13 +1068,26 @@ export const ko: Record = { "usage.companion.placeholders": "자리표시자:", "usage.companion.modelsOnChart": "차트의 모델", "usage.companion.connected": "메뉴 막대 앱 연결됨 · {age}", - "usage.companion.installTitle": "메뉴 막대 앱 설치", - "usage.companion.installStep1": "최신 릴리스에서 OpenCodex--macos-universal.zip을 다운로드하고 OpenCodex.app을 응용 프로그램으로 드래그하세요.", - "usage.companion.installStep2": "첫 실행: OpenCodex.app을 마우스 오른쪽 버튼으로 클릭하고 열기를 선택하세요(앱이 애드혹 서명되어 Gatekeeper가 한 번 확인합니다).", - "usage.companion.installStep3": "앱이 이 프록시를 자동으로 찾습니다. 앱을 실행하면 위젯 갤러리에 위젯이 표시됩니다.", + "usage.companion.connectedDesktop": "데스크톱 앱 연결됨 · {age}", + "usage.companion.installTitle": "데스크톱 앱 설치", + "usage.companion.installOs": "운영 체제", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "OpenCodex 데스크톱 앱 {version}에서 실행 중입니다", + "usage.companion.openInBrowser": "브라우저에서 열기", + "usage.companion.installMacStep1": "최신 릴리스에서 OpenCodex--macos.dmg를 다운로드하고 OpenCodex.app을 응용 프로그램으로 드래그하세요.", + "usage.companion.installMacStep2": "첫 실행: OpenCodex.app을 마우스 오른쪽 버튼으로 클릭하고 열기를 선택하세요(공증될 때까지 Gatekeeper가 한 번 확인합니다).", + "usage.companion.installMacStep3": "앱이 이 프록시를 자동으로 찾으며, 앱을 실행하면 위젯 갤러리에 위젯이 표시됩니다.", + "usage.companion.installWinStep1": "최신 릴리스에서 OpenCodex--windows-x64.msi를 다운로드하고 실행하세요.", + "usage.companion.installWinStep2": "SmartScreen 경고가 표시되면 추가 정보 → 실행을 선택하세요(설치 프로그램은 아직 코드 서명되지 않았습니다).", + "usage.companion.installWinStep3": "OpenCodex가 시스템 트레이에 나타나 이 프록시에 연결하거나 번들 프록시를 시작합니다.", + "usage.companion.installLinuxStep1": "최신 릴리스에서 OpenCodex--linux-x86_64.AppImage(또는 .deb)를 다운로드하세요.", + "usage.companion.installLinuxStep2": "AppImage에 chmod +x를 실행하고 시작하세요. 트레이 아이콘에는 AppIndicator를 지원하는 데스크톱이 필요합니다.", + "usage.companion.installLinuxStep3": "앱이 이 프록시에 연결하거나 번들 프록시를 시작합니다.", "usage.companion.notConnected": "아직 이 프록시에 연결한 메뉴 막대 앱이 없습니다.", "usage.companion.lastSeen": "마지막 연결 {age}", - "usage.companion.installAnother": "다른 Mac에 설치", + "usage.companion.installAnother": "다른 기기에 설치", "usage.companion.modelsCount": "{selected} / {total}개가 차트에 표시됨", "usage.companion.modelsShowAll": "모두 표시", "usage.companion.hideProviders": "공급자 숨기기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index af9681a2d84..c0002fc2aca 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1004,7 +1004,7 @@ export const ru: Record = { "usage.section.coverage": "Детализация покрытия", "usage.section.companion": "Строка меню и виджет", "usage.companion.title": "Строка меню и виджет", - "usage.companion.description": "Эти настройки управляют приложением OpenCodex в строке меню и его виджетом.", + "usage.companion.description": "Эти настройки управляют настольным приложением/приложением в строке меню OpenCodex и его виджетом.", "usage.companion.installGuide": "Руководство по установке", "usage.companion.loading": "Загрузка временной шкалы…", "usage.companion.timelineUnavailable": "Временная шкала недоступна", @@ -1055,13 +1055,26 @@ export const ru: Record = { "usage.companion.placeholders": "Заполнители:", "usage.companion.modelsOnChart": "Модели на графике", "usage.companion.connected": "Приложение в строке меню подключено · {age}", - "usage.companion.installTitle": "Установить приложение в строке меню", - "usage.companion.installStep1": "Скачайте OpenCodex--macos-universal.zip из последнего релиза и перетащите OpenCodex.app в Программы.", - "usage.companion.installStep2": "Первый запуск: нажмите OpenCodex.app правой кнопкой и выберите «Открыть» (приложение подписано ad-hoc, поэтому Gatekeeper спросит один раз).", - "usage.companion.installStep3": "Приложение само найдёт этот прокси; виджет появится в галерее виджетов после запуска приложения.", + "usage.companion.connectedDesktop": "Настольное приложение подключено · {age}", + "usage.companion.installTitle": "Установить настольное приложение", + "usage.companion.installOs": "Операционная система", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "Вы находитесь в настольном приложении OpenCodex {version}", + "usage.companion.openInBrowser": "Открыть в браузере", + "usage.companion.installMacStep1": "Скачайте OpenCodex--macos.dmg из последнего релиза и перетащите OpenCodex.app в Программы.", + "usage.companion.installMacStep2": "Первый запуск: нажмите OpenCodex.app правой кнопкой и выберите «Открыть» (до нотариальной заверки Gatekeeper спросит один раз).", + "usage.companion.installMacStep3": "Приложение само найдёт этот прокси; после запуска приложение появится в галерее виджетов.", + "usage.companion.installWinStep1": "Скачайте OpenCodex--windows-x64.msi из последнего релиза и запустите его.", + "usage.companion.installWinStep2": "Если SmartScreen предупредит, выберите Подробнее → Всё равно запустить (установщик ещё не подписан).", + "usage.companion.installWinStep3": "OpenCodex появится в системном трее и подключится к этому прокси или запустит встроенный.", + "usage.companion.installLinuxStep1": "Скачайте OpenCodex--linux-x86_64.AppImage (или .deb) из последнего релиза.", + "usage.companion.installLinuxStep2": "Выполните chmod +x для AppImage и запустите его; для значка в трее нужен рабочий стол с поддержкой AppIndicator.", + "usage.companion.installLinuxStep3": "Приложение подключится к этому прокси или запустит встроенный.", "usage.companion.notConnected": "К этому прокси ещё не подключалось приложение из строки меню.", "usage.companion.lastSeen": "Последнее подключение: {age}", - "usage.companion.installAnother": "Установить на другом Mac", + "usage.companion.installAnother": "Установить на другом устройстве", "usage.companion.modelsCount": "{selected} из {total} на графике", "usage.companion.modelsShowAll": "Показать все", "usage.companion.hideProviders": "Скрыть провайдеров", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index db62526abb9..528387f39da 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1023,7 +1023,7 @@ export const tr: Record = { "usage.section.coverage": "Kapsam dağılımı", "usage.section.companion": "Menü çubuğu ve widget", "usage.companion.title": "Menü çubuğu ve widget", - "usage.companion.description": "Buradaki ayarlar OpenCodex menü çubuğu uygulamasını ve widget'ını yönetir.", + "usage.companion.description": "Buradaki ayarlar OpenCodex masaüstü/menü çubuğu uygulamasını ve widget’ını yönetir.", "usage.companion.installGuide": "Kurulum rehberi", "usage.companion.loading": "Zaman çizelgesi yükleniyor…", "usage.companion.timelineUnavailable": "Zaman çizelgesi kullanılamıyor", @@ -1074,13 +1074,26 @@ export const tr: Record = { "usage.companion.placeholders": "Yer tutucular:", "usage.companion.modelsOnChart": "Grafikteki modeller", "usage.companion.connected": "Menü çubuğu uygulaması bağlı · {age}", - "usage.companion.installTitle": "Menü çubuğu uygulamasını yükle", - "usage.companion.installStep1": "En son sürümden OpenCodex--macos-universal.zip dosyasını indirin ve OpenCodex.app'i Uygulamalar'a sürükleyin.", - "usage.companion.installStep2": "İlk çalıştırma: OpenCodex.app'e sağ tıklayıp Aç'ı seçin (uygulama ad-hoc imzalıdır; Gatekeeper bir kez sorar).", - "usage.companion.installStep3": "Uygulama bu proxy'yi kendisi bulur; uygulama çalıştıktan sonra widget, widget galerisinde görünür.", + "usage.companion.connectedDesktop": "Masaüstü uygulaması bağlı · {age}", + "usage.companion.installTitle": "Masaüstü uygulamasını yükle", + "usage.companion.installOs": "İşletim sistemi", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "OpenCodex masaüstü uygulaması {version} içindesiniz", + "usage.companion.openInBrowser": "Tarayıcıda aç", + "usage.companion.installMacStep1": "En son sürümden OpenCodex--macos.dmg dosyasını indirin ve OpenCodex.app’i Uygulamalar’a sürükleyin.", + "usage.companion.installMacStep2": "İlk çalıştırma: OpenCodex.app’e sağ tıklayıp Aç’ı seçin (uygulama not edilene kadar Gatekeeper bir kez sorar).", + "usage.companion.installMacStep3": "Uygulama bu proxy’yi kendisi bulur; uygulama çalıştıktan sonra widget galeride görünür.", + "usage.companion.installWinStep1": "En son sürümden OpenCodex--windows-x64.msi dosyasını indirin ve çalıştırın.", + "usage.companion.installWinStep2": "SmartScreen uyarırsa Daha fazla bilgi → Yine de çalıştır seçeneğini seçin (yükleyici henüz kod imzalı değil).", + "usage.companion.installWinStep3": "OpenCodex sistem tepsisinde görünür ve bu proxy’ye bağlanır veya paketlenmiş olanı başlatır.", + "usage.companion.installLinuxStep1": "En son sürümden OpenCodex--linux-x86_64.AppImage (veya .deb) dosyasını indirin.", + "usage.companion.installLinuxStep2": "AppImage için chmod +x çalıştırıp başlatın; tepsi simgesi AppIndicator destekli bir masaüstü gerektirir.", + "usage.companion.installLinuxStep3": "Uygulama bu proxy’ye bağlanır veya paketlenmiş olanı başlatır.", "usage.companion.notConnected": "Bu proxy'ye henüz hiçbir menü çubuğu uygulaması bağlanmadı.", "usage.companion.lastSeen": "Son görülme {age}", - "usage.companion.installAnother": "Başka bir Mac'e yükle", + "usage.companion.installAnother": "Başka bir cihaza yükle", "usage.companion.modelsCount": "Grafikte {selected}/{total}", "usage.companion.modelsShowAll": "Tümünü göster", "usage.companion.hideProviders": "Sağlayıcıları gizle", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 749fde103c8..337335fdbd9 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1006,7 +1006,7 @@ export const vi: Record = { "usage.section.coverage": "Chi tiết độ phủ (Coverage breakdown)", "usage.section.companion": "Thanh menu và widget", "usage.companion.title": "Thanh menu và widget", - "usage.companion.description": "Các cài đặt ở đây điều khiển ứng dụng thanh menu OpenCodex và widget.", + "usage.companion.description": "Các cài đặt ở đây điều khiển ứng dụng máy tính/thanh menu OpenCodex và widget của ứng dụng.", "usage.companion.installGuide": "Hướng dẫn cài đặt", "usage.companion.loading": "Đang tải dòng thời gian…", "usage.companion.timelineUnavailable": "Không có dòng thời gian", @@ -1057,13 +1057,26 @@ export const vi: Record = { "usage.companion.placeholders": "Trình giữ chỗ:", "usage.companion.modelsOnChart": "Mô hình trên biểu đồ", "usage.companion.connected": "Ứng dụng trên thanh menu đã kết nối · {age}", - "usage.companion.installTitle": "Cài đặt ứng dụng trên thanh menu", - "usage.companion.installStep1": "Tải OpenCodex--macos-universal.zip từ bản phát hành mới nhất và kéo OpenCodex.app vào Applications.", - "usage.companion.installStep2": "Lần đầu mở: nhấp chuột phải vào OpenCodex.app → Mở (ứng dụng được ký ad-hoc nên Gatekeeper chỉ hỏi một lần).", - "usage.companion.installStep3": "Ứng dụng tự tìm proxy này; widget sẽ xuất hiện trong thư viện widget sau khi ứng dụng chạy.", + "usage.companion.connectedDesktop": "Ứng dụng máy tính đã kết nối · {age}", + "usage.companion.installTitle": "Cài đặt ứng dụng máy tính", + "usage.companion.installOs": "Hệ điều hành", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "Bạn đang ở trong ứng dụng máy tính OpenCodex {version}", + "usage.companion.openInBrowser": "Mở trong trình duyệt", + "usage.companion.installMacStep1": "Tải OpenCodex--macos.dmg từ bản phát hành mới nhất và kéo OpenCodex.app vào Applications.", + "usage.companion.installMacStep2": "Lần đầu mở: nhấp chuột phải vào OpenCodex.app → Mở (Gatekeeper sẽ hỏi một lần cho đến khi ứng dụng được công chứng).", + "usage.companion.installMacStep3": "Ứng dụng tự tìm proxy này; widget xuất hiện trong thư viện widget sau khi ứng dụng chạy.", + "usage.companion.installWinStep1": "Tải OpenCodex--windows-x64.msi từ bản phát hành mới nhất và chạy trình cài đặt.", + "usage.companion.installWinStep2": "Nếu SmartScreen cảnh báo, chọn Thông tin thêm → Vẫn chạy (trình cài đặt chưa được ký mã).", + "usage.companion.installWinStep3": "OpenCodex xuất hiện trong khay hệ thống và kết nối proxy này, hoặc khởi chạy proxy đi kèm.", + "usage.companion.installLinuxStep1": "Tải OpenCodex--linux-x86_64.AppImage (hoặc .deb) từ bản phát hành mới nhất.", + "usage.companion.installLinuxStep2": "Chạy chmod +x cho AppImage rồi mở; biểu tượng khay cần môi trường desktop hỗ trợ AppIndicator.", + "usage.companion.installLinuxStep3": "Ứng dụng kết nối proxy này hoặc khởi chạy proxy đi kèm.", "usage.companion.notConnected": "Chưa có ứng dụng trên thanh menu nào kết nối với proxy này.", "usage.companion.lastSeen": "Lần kết nối gần nhất {age}", - "usage.companion.installAnother": "Cài đặt trên máy Mac khác", + "usage.companion.installAnother": "Cài đặt trên thiết bị khác", "usage.companion.modelsCount": "{selected}/{total} trên biểu đồ", "usage.companion.modelsShowAll": "Hiện tất cả", "usage.companion.hideProviders": "Ẩn nhà cung cấp", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 62018401005..28a7650bc16 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -814,7 +814,7 @@ export const zhTW: Record = { "usage.section.coverage": "覆蓋率明細", "usage.section.companion": "選單列與小工具", "usage.companion.title": "選單列與小工具", - "usage.companion.description": "這裡的設定會控制 OpenCodex 選單列 App 與其小工具。", + "usage.companion.description": "這裡的設定會控制 OpenCodex 桌面/選單列 App 與其小工具。", "usage.companion.installGuide": "安裝指南", "usage.companion.loading": "正在載入時間軸…", "usage.companion.timelineUnavailable": "時間軸無法使用", @@ -865,13 +865,26 @@ export const zhTW: Record = { "usage.companion.placeholders": "預留位置:", "usage.companion.modelsOnChart": "圖表中的模型", "usage.companion.connected": "選單列 App 已連線 · {age}", - "usage.companion.installTitle": "安裝選單列 App", - "usage.companion.installStep1": "從最新版本下載 OpenCodex--macos-universal.zip,並將 OpenCodex.app 拖到應用程式。", - "usage.companion.installStep2": "首次啟動:在 OpenCodex.app 上按右鍵並選擇「打開」(App 使用臨時簽章,因此 Gatekeeper 只會詢問一次)。", - "usage.companion.installStep3": "App 會自動找到此 Proxy;App 執行後,Widget 會出現在 Widget 圖庫中。", + "usage.companion.connectedDesktop": "桌面 App 已連線 · {age}", + "usage.companion.installTitle": "安裝桌面 App", + "usage.companion.installOs": "作業系統", + "usage.companion.osMac": "macOS 系統", + "usage.companion.osWindows": "Windows 系統", + "usage.companion.osLinux": "Linux 系統", + "usage.companion.runningInDesktop": "你正在使用 OpenCodex 桌面 App {version}", + "usage.companion.openInBrowser": "在瀏覽器中開啟", + "usage.companion.installMacStep1": "從最新版本下載 OpenCodex--macos.dmg,並將 OpenCodex.app 拖到應用程式。", + "usage.companion.installMacStep2": "首次啟動:在 OpenCodex.app 上按右鍵並選擇「開啟」(App 完成公證前,Gatekeeper 只會詢問一次)。", + "usage.companion.installMacStep3": "App 會自動找到此 Proxy;App 執行後,Widget 會出現在 Widget 圖庫中。", + "usage.companion.installWinStep1": "從最新版本下載 OpenCodex--windows-x64.msi 並執行。", + "usage.companion.installWinStep2": "如果 SmartScreen 發出警告,請選擇「更多資訊」→「仍要執行」(安裝程式尚未完成程式碼簽署)。", + "usage.companion.installWinStep3": "OpenCodex 會出現在系統匣並連線此 Proxy,或啟動內建 Proxy。", + "usage.companion.installLinuxStep1": "從最新版本下載 OpenCodex--linux-x86_64.AppImage(或 .deb)。", + "usage.companion.installLinuxStep2": "對 AppImage 執行 chmod +x 後啟動;系統匣圖示需要支援 AppIndicator 的桌面環境。", + "usage.companion.installLinuxStep3": "App 會連線此 Proxy,或啟動內建 Proxy。", "usage.companion.notConnected": "尚未有選單列 App 連線到此 Proxy。", "usage.companion.lastSeen": "上次連線 {age}", - "usage.companion.installAnother": "在另一台 Mac 上安裝", + "usage.companion.installAnother": "在另一台裝置上安裝", "usage.companion.modelsCount": "圖表顯示 {selected}/{total}", "usage.companion.modelsShowAll": "顯示全部", "usage.companion.hideProviders": "隱藏提供者", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 17a93d78dec..7caef604372 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -998,7 +998,7 @@ export const zh: Record = { "usage.section.coverage": "覆盖率明细", "usage.section.companion": "菜单栏与小组件", "usage.companion.title": "菜单栏与小组件", - "usage.companion.description": "此处设置会控制 OpenCodex 菜单栏应用及其小组件。", + "usage.companion.description": "这里的设置会控制 OpenCodex 桌面/菜单栏应用及其小组件。", "usage.companion.installGuide": "安装指南", "usage.companion.loading": "正在加载时间线…", "usage.companion.timelineUnavailable": "时间线不可用", @@ -1049,13 +1049,26 @@ export const zh: Record = { "usage.companion.placeholders": "占位符:", "usage.companion.modelsOnChart": "图表中的模型", "usage.companion.connected": "菜单栏应用已连接 · {age}", - "usage.companion.installTitle": "安装菜单栏应用", - "usage.companion.installStep1": "从最新版本下载 OpenCodex--macos-universal.zip,并将 OpenCodex.app 拖到应用程序。", - "usage.companion.installStep2": "首次启动:右键点击 OpenCodex.app 并选择“打开”(应用使用临时签名,因此 Gatekeeper 只会询问一次)。", - "usage.companion.installStep3": "应用会自动找到此代理;应用运行后,小组件会出现在小组件图库中。", + "usage.companion.connectedDesktop": "桌面应用已连接 · {age}", + "usage.companion.installTitle": "安装桌面应用", + "usage.companion.installOs": "操作系统", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "你正在使用 OpenCodex 桌面应用 {version}", + "usage.companion.openInBrowser": "在浏览器中打开", + "usage.companion.installMacStep1": "从最新版本下载 OpenCodex--macos.dmg,并将 OpenCodex.app 拖到应用程序。", + "usage.companion.installMacStep2": "首次启动:右键点击 OpenCodex.app 并选择“打开”(应用完成公证前,Gatekeeper 只会询问一次)。", + "usage.companion.installMacStep3": "应用会自动找到此代理;应用运行后,小组件会出现在小组件图库中。", + "usage.companion.installWinStep1": "从最新版本下载 OpenCodex--windows-x64.msi 并运行。", + "usage.companion.installWinStep2": "如果 SmartScreen 发出警告,请选择“更多信息”→“仍要运行”(安装程序尚未经过代码签名)。", + "usage.companion.installWinStep3": "OpenCodex 会出现在系统托盘中并连接此代理,或启动内置代理。", + "usage.companion.installLinuxStep1": "从最新版本下载 OpenCodex--linux-x86_64.AppImage(或 .deb)。", + "usage.companion.installLinuxStep2": "对 AppImage 执行 chmod +x 后运行;托盘图标需要支持 AppIndicator 的桌面环境。", + "usage.companion.installLinuxStep3": "应用会连接此代理,或启动内置代理。", "usage.companion.notConnected": "尚未有菜单栏应用连接到此代理。", "usage.companion.lastSeen": "上次连接 {age}", - "usage.companion.installAnother": "在另一台 Mac 上安装", + "usage.companion.installAnother": "在另一台设备上安装", "usage.companion.modelsCount": "图表显示 {selected}/{total}", "usage.companion.modelsShowAll": "显示全部", "usage.companion.hideProviders": "隐藏提供商", diff --git a/gui/src/lib/desktop-shell.ts b/gui/src/lib/desktop-shell.ts new file mode 100644 index 00000000000..f6301ee1192 --- /dev/null +++ b/gui/src/lib/desktop-shell.ts @@ -0,0 +1,32 @@ +export type HostOs = "macos" | "windows" | "linux" | "unknown"; + +function currentUserAgent(): string { + return typeof navigator === "undefined" ? "" : navigator.userAgent; +} + +export function desktopShellVersion(ua = currentUserAgent()): string | null { + return ua.match(/OpenCodexDesktop\/(\S+)/)?.[1] ?? null; +} + +export function isDesktopShell(ua = currentUserAgent()): boolean { + return desktopShellVersion(ua) !== null; +} + +export function hostOs(ua = currentUserAgent()): HostOs { + if (/Windows/i.test(ua)) return "windows"; + if (/Mac OS X|Macintosh/i.test(ua)) return "macos"; + if (/Linux|X11/i.test(ua) && !/Android/i.test(ua)) return "linux"; + return "unknown"; +} + +export function isExternalLink( + href: string, + origin = typeof location === "undefined" ? "" : location.origin, +): boolean { + if (!/^https?:\/\//i.test(href)) return false; + try { + return new URL(href).origin !== origin; + } catch { + return false; + } +} diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index f3af2082de1..44dd4f2dff2 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { useI18n } from "../i18n/shared"; +import { useI18n, type TFn, type TKey } from "../i18n/shared"; import { relativeTimeLabelsFromT, formatRelativeTime } from "../provider-workspace/usage"; import { Switch } from "../ui"; import { UsageCompanionChart } from "./usage-companion-chart"; +import { desktopShellVersion, hostOs, isDesktopShell, type HostOs } from "../lib/desktop-shell"; import { bucketMinutesForWindow, buildCompanionSettingsPatch, @@ -24,6 +25,50 @@ const CHART_STYLES = ["line", "stackedBar"] as const; const TOKEN_METRICS = ["total", "input", "output", "cached"] as const; const AGGREGATIONS = ["sum", "average", "max"] as const; const GROUPINGS = ["model", "modelAccount"] as const; +const MENU_METRIC_KEYS: Record<(typeof MENU_METRICS)[number], TKey> = { + requests: "usage.companion.menuRequests", + tokens: "usage.companion.menuTokens", + cost: "usage.companion.menuCost", + quota: "usage.companion.menuQuota", + none: "usage.companion.menuNone", +}; +const WINDOW_KEYS: Record<(typeof WINDOWS)[number], TKey> = { + 6: "usage.companion.window6", + 24: "usage.companion.window24", + 72: "usage.companion.window72", + 168: "usage.companion.window168", +}; +const TOKEN_METRIC_KEYS: Record<(typeof TOKEN_METRICS)[number], TKey> = { + total: "usage.companion.metricTotal", + input: "usage.companion.metricInput", + output: "usage.companion.metricOutput", + cached: "usage.companion.metricCached", +}; +const SECTION_OPTIONS = [ + ["showToday", "usage.companion.sectionToday"], + ["showChart", "usage.companion.sectionChart"], + ["showModels", "usage.companion.sectionModels"], + ["showCost", "usage.companion.sectionCost"], + ["showAccounts", "usage.companion.sectionAccounts"], +] as const; +const AGGREGATION_KEYS: Record<(typeof AGGREGATIONS)[number], TKey> = { + sum: "usage.companion.aggregationSum", + average: "usage.companion.aggregationAverage", + max: "usage.companion.aggregationMax", +}; +type InstallOs = Exclude; + +const INSTALL_STEP_KEYS: Record = { + macos: ["usage.companion.installMacStep1", "usage.companion.installMacStep2", "usage.companion.installMacStep3"], + windows: ["usage.companion.installWinStep1", "usage.companion.installWinStep2", "usage.companion.installWinStep3"], + linux: ["usage.companion.installLinuxStep1", "usage.companion.installLinuxStep2", "usage.companion.installLinuxStep3"], +}; + +const OS_LABEL_KEYS: Record = { + macos: "usage.companion.osMac", + windows: "usage.companion.osWindows", + linux: "usage.companion.osLinux", +}; function formatSaveTime(value: number, locale: string): string { return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(value); @@ -34,6 +79,26 @@ function errorMessage(value: unknown): string { return String(value); } +function OsSelector({ + value, + onChange, + t, +}: { + value: InstallOs; + onChange: (value: InstallOs) => void; + t: TFn; +}) { + return ( +
+ {(Object.keys(OS_LABEL_KEYS) as InstallOs[]).map(os => ( + + ))} +
+ ); +} + function Segment({ label, value, @@ -129,6 +194,10 @@ export default function UsageCompanionPanel({ const settingsRef = useRef(settings); const knownTotalsRef = useRef(new Map()); const [knownTotals, setKnownTotals] = useState>(new Map()); + const [installOs, setInstallOs] = useState(() => { + const detected = hostOs(); + return detected === "unknown" ? "macos" : detected; + }); useEffect(() => { saveStateRef.current = saveState; @@ -275,6 +344,20 @@ export default function UsageCompanionPanel({ } }, [apiBase, loadSettings]); + const openInBrowser = useCallback(async () => { + try { + const path = location.hash ? `/${location.hash}` : "/#/usage"; + const result = await fetch(`${apiBase}/api/companion/open-in-browser`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + } catch (error) { + setSaveError(errorMessage(error)); + } + }, [apiBase]); + if (settingsError) { return

{t("usage.companion.settingsUnavailable")}

; } @@ -307,29 +390,51 @@ export default function UsageCompanionPanel({ const lastSeenAt = response?.companion?.lastSeenAt ?? null; const connected = lastSeenAt !== null && fetchedAt !== null && fetchedAt - lastSeenAt <= 10 * 60 * 1000; const age = lastSeenAt === null || fetchedAt === null ? "" : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t), fetchedAt); + const shell = isDesktopShell(); + const shellVersion = desktopShellVersion() ?? __APP_VERSION__; + const stepKeys = INSTALL_STEP_KEYS[installOs]; const steps = (
    -
  1. {t("usage.companion.installStep1")} {t("common.github")}
  2. -
  3. {t("usage.companion.installStep2")}
  4. -
  5. {t("usage.companion.installStep3")}
  6. +
  7. {t(stepKeys[0])} {t("common.github")}
  8. +
  9. {t(stepKeys[1])}
  10. +
  11. {t(stepKeys[2])}
); + const installCommand = installOs === "macos" + ? xattr -d com.apple.quarantine /Applications/OpenCodex.app + : installOs === "linux" + ? chmod +x OpenCodex-*.AppImage + : null; + const installGuidance = ( +
+ {t("usage.companion.installAnother")} + + {steps} + {installCommand} +
+ ); + if (shell) { + return ( +
+

{t("usage.companion.runningInDesktop", { version: shellVersion })}

+ + {installGuidance} +
+ ); + } return connected ? (
-
-
- {t("usage.companion.installAnother")} - {steps} - xattr -d com.apple.quarantine /Applications/OpenCodex.app -
+
+ {installGuidance}
) : (
{t("usage.companion.installTitle")} {lastSeenAt !== null &&

{t("usage.companion.lastSeen", { age })}

} {lastSeenAt === null &&

{t("usage.companion.notConnected")}

} + {steps} - xattr -d com.apple.quarantine /Applications/OpenCodex.app + {installCommand}
); })()} @@ -378,30 +483,24 @@ export default function UsageCompanionPanel({

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 B$)*4R literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..78c4c7833545da805e10394696bd5732dfdba926 GIT binary patch literal 11591 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 Z?Dq?7Sz|7_xbNx$`K*0rS$5A=W{{8C@=?7ir- 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 LNkvXXu0mjfF|b_g literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/src/auth.rs b/desktop/src-tauri/src/auth.rs new file mode 100644 index 00000000000..81a16467b05 --- /dev/null +++ b/desktop/src-tauri/src/auth.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Auth { + home: PathBuf, + environment_token: Option, +} + +impl Auth { + pub fn new(home: PathBuf) -> Self { + Self { + home, + environment_token: std::env::var("OPENCODEX_ADMIN_AUTH_TOKEN") + .ok() + .filter(|value| !value.is_empty()), + } + } + + pub fn token(&self) -> Option { + self.environment_token.clone().or_else(|| { + std::fs::read_to_string(self.home.join("admin-api-token")) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + }) + } + + pub fn user_agent() -> &'static str { + concat!("OpenCodexDesktop/", env!("CARGO_PKG_VERSION")) + } +} diff --git a/desktop/src-tauri/src/discovery.rs b/desktop/src-tauri/src/discovery.rs new file mode 100644 index 00000000000..2eaded758df --- /dev/null +++ b/desktop/src-tauri/src/discovery.rs @@ -0,0 +1,116 @@ +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_PORT: u16 = 10100; +const HOME_ENV: &str = "OPENCODEX_HOME"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxyEndpoint { + pub host: &'static str, + pub port: u16, +} + +impl ProxyEndpoint { + pub fn url(&self, path: &str) -> String { + format!("http://{}:{}{}", self.host, self.port, path) + } +} + +#[derive(Debug, Deserialize)] +struct RuntimePort { + port: u16, +} + +pub fn config_directory(environment: impl Fn(&str) -> Option, home: &Path) -> PathBuf { + environment(HOME_ENV) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .map(|value| expand_tilde(PathBuf::from(value), home)) + .unwrap_or_else(|| home.join(".opencodex")) +} + +pub fn resolve(environment: impl Fn(&str) -> Option, home: &Path) -> ProxyEndpoint { + let directory = config_directory(environment, home); + let path = directory.join("runtime-port.json"); + let port = std::fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .map(|record| record.port) + .filter(|port| (1..=u16::MAX).contains(port)) + .unwrap_or(DEFAULT_PORT); + ProxyEndpoint { + host: "127.0.0.1", + port, + } +} + +fn expand_tilde(path: PathBuf, home: &Path) -> PathBuf { + if path == Path::new("~") { + return home.to_path_buf(); + } + path.strip_prefix("~/") + .map(|rest| home.join(rest)) + .unwrap_or(path) +} + +pub fn current() -> (ProxyEndpoint, PathBuf) { + let home = dirs_home(); + let directory = config_directory(|key| std::env::var(key).ok(), &home); + let endpoint = resolve(|key| std::env::var(key).ok(), &home); + (endpoint, directory) +} + +fn dirs_home() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{create_dir_all, write}; + + #[test] + fn resolves_home_override_and_runtime_port() { + let root = std::env::temp_dir().join(format!("ocx-discovery-{}", std::process::id())); + let home = root.join("home"); + let custom = home.join("custom"); + create_dir_all(&custom).unwrap(); + write( + custom.join("runtime-port.json"), + r#"{"pid":1,"port":12345}"#, + ) + .unwrap(); + let endpoint = resolve(|key| (key == HOME_ENV).then(|| "~/custom".into()), &home); + assert_eq!( + endpoint, + ProxyEndpoint { + host: "127.0.0.1", + port: 12345 + } + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn any_failure_falls_back_to_default() { + let home = std::env::temp_dir().join("ocx-missing-home"); + let endpoint = resolve(|_| None, &home); + assert_eq!(endpoint.port, DEFAULT_PORT); + } + + #[test] + fn empty_override_and_invalid_port_use_default() { + let root = + std::env::temp_dir().join(format!("ocx-discovery-invalid-{}", std::process::id())); + let home = root.join("home"); + let directory = root.join("custom"); + create_dir_all(&directory).unwrap(); + write(directory.join("runtime-port.json"), r#"{"port":0}"#).unwrap(); + let endpoint = resolve(|key| (key == HOME_ENV).then(|| " ".into()), &home); + assert_eq!(endpoint.port, DEFAULT_PORT); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/desktop/src-tauri/src/formatting.rs b/desktop/src-tauri/src/formatting.rs new file mode 100644 index 00000000000..5f940b68fed --- /dev/null +++ b/desktop/src-tauri/src/formatting.rs @@ -0,0 +1,79 @@ +pub fn tokens(value: Option) -> String { + abbreviate(value, true) +} + +pub fn count(value: Option) -> String { + abbreviate(value, false) +} + +pub fn cost(value: Option) -> String { + let Some(value) = value else { + return "—".into(); + }; + if value < 1_000.0 { + return format!("${value:.2}"); + } + format!("${}", abbreviate_float(value, false)) +} + +fn abbreviate(value: Option, integer: bool) -> String { + let Some(value) = value else { + return "—".into(); + }; + if !integer && value < 10_000 { + return format!("{value}"); + } + if integer && value < 1_000 { + return format!("{value}"); + } + abbreviate_float(value as f64, integer) +} + +fn abbreviate_float(value: f64, integer: bool) -> String { + let units = [ + (1_000_000_000_000.0, "T"), + (1_000_000_000.0, "B"), + (1_000_000.0, "M"), + (1_000.0, "K"), + ]; + for (threshold, suffix) in units { + if value >= threshold * 0.9995 { + let scaled = value / threshold; + let decimals = if integer || scaled >= 100.0 { + 0 + } else if scaled >= 10.0 { + 1 + } else { + 2 + }; + let rendered = format!("{scaled:.decimals$}"); + return format!( + "{}{}", + rendered.trim_end_matches('0').trim_end_matches('.'), + suffix + ); + } + } + format!("{value:.0}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_boundaries_match_swift_formatting() { + assert_eq!(tokens(Some(999_600)), "1M"); + assert_eq!(tokens(Some(1_234)), "1K"); + assert_eq!(tokens(Some(2_401_634_303)), "2B"); + assert_eq!(tokens(None), "—"); + } + + #[test] + fn counts_and_costs_have_expected_precision() { + assert_eq!(count(Some(9_999)), "9999"); + assert_eq!(count(Some(12_345)), "12.3K"); + assert_eq!(cost(Some(12.345)), "$12.35"); + assert_eq!(cost(Some(1_234.0)), "$1.23K"); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 00000000000..aa688daca9f --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,119 @@ +mod auth; +mod discovery; +mod formatting; +mod logging; +mod proxy; +mod sidecar; +mod tray; +mod updater; +mod widget; +mod window; + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri_plugin_autostart::MacosLauncher; +use tauri_plugin_shell::process::CommandChild; + +pub struct AppState { + pub proxy: proxy::ProxyClient, + pub spawned_by_us: AtomicBool, + pub child: Mutex>, +} + +impl AppState { + pub fn shutdown_child(&self) { + if !self.spawned_by_us.swap(false, Ordering::AcqRel) { + return; + } + if let Ok(mut child) = self.child.lock() { + if let Some(child) = child.take() { + let _ = child.kill(); + } + } + } +} + +#[tauri::command] +fn show_dashboard(app: tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } +} + +#[tauri::command] +fn hide_dashboard(app: tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + window::hide(&window); + } +} + +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } + })) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_autostart::init( + MacosLauncher::LaunchAgent, + None, + )) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .invoke_handler(tauri::generate_handler![show_dashboard, hide_dashboard]) + .setup(|app| { + let (endpoint, home) = discovery::current(); + let proxy = proxy::ProxyClient::new(endpoint, auth::Auth::new(home)) + .map_err(|error| error.to_string())?; + let child = tauri::async_runtime::block_on(sidecar::ensure_proxy( + app.handle(), + &proxy, + endpoint, + )) + .map_err(std::io::Error::other)?; + app.manage(AppState { + proxy: proxy.clone(), + spawned_by_us: AtomicBool::new(child.is_some()), + child: Mutex::new(child), + }); + app.manage(updater::PendingUpdate(Mutex::new(None))); + app.manage(tray::TrayState::default()); + + let window = WebviewWindowBuilder::new( + app, + "main", + WebviewUrl::App(format!("index.html?port={}", endpoint.port).into()), + ) + .title("OpenCodex") + .inner_size(1100.0, 720.0) + .visible(false) + .user_agent(&window::webview_user_agent()) + .on_navigation(window::navigation_allowed(endpoint)) + .build()?; + window::configure(&window); + window::set_tray_policy(app.handle(), false); + let dashboard = endpoint.url("/#/usage"); + if tauri::async_runtime::block_on(proxy.is_alive()).is_ok() { + let _ = window.eval(format!("window.location.replace({dashboard:?})")); + } + tray::install(app.handle(), proxy)?; + if !cfg!(debug_assertions) { + updater::start_background_checks(app.handle().clone()); + } + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while building OpenCodex desktop shell") + .run(|app, event| { + if matches!(event, tauri::RunEvent::Exit) { + if let Some(state) = app.try_state::() { + state.shutdown_child(); + } + } + }); +} diff --git a/desktop/src-tauri/src/logging.rs b/desktop/src-tauri/src/logging.rs new file mode 100644 index 00000000000..a3b71fb5f47 --- /dev/null +++ b/desktop/src-tauri/src/logging.rs @@ -0,0 +1,14 @@ +use std::{ + collections::HashSet, + sync::{Mutex, OnceLock}, +}; + +pub fn log_once(scope: &str, message: &str) { + static LOGGED: OnceLock>> = OnceLock::new(); + let logged = LOGGED.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut logged) = logged.lock() { + if logged.insert(format!("{scope}: {message}")) { + eprintln!("{scope}: {message}"); + } + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 00000000000..c0e7716829b --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + opencodex_desktop_lib::run(); +} diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs new file mode 100644 index 00000000000..fc8dc71ec5e --- /dev/null +++ b/desktop/src-tauri/src/proxy.rs @@ -0,0 +1,111 @@ +use crate::{auth::Auth, discovery::ProxyEndpoint}; +use reqwest::{Client, Method, StatusCode}; +use serde_json::Value; +use std::time::Duration; + +#[derive(Clone)] +pub struct ProxyClient { + client: Client, + endpoint: ProxyEndpoint, + auth: Auth, +} + +#[derive(Debug)] +pub enum ProxyError { + Unreachable, + Unauthorized, + Http(StatusCode), + Decode(reqwest::Error), +} + +impl ProxyClient { + pub fn new(endpoint: ProxyEndpoint, auth: Auth) -> Result { + Ok(Self { + client: Client::builder() + .timeout(Duration::from_secs(4)) + .user_agent(Auth::user_agent()) + .build()?, + endpoint, + auth, + }) + } + + pub fn endpoint(&self) -> ProxyEndpoint { + self.endpoint + } + + pub async fn is_alive(&self) -> Result { + self.get("/healthz").await + } + + pub async fn companion_settings(&self) -> Result { + self.get("/api/companion/settings").await + } + + pub async fn usage_summary(&self) -> Result { + self.get("/api/usage?range=7d").await + } + + pub async fn usage_today(&self) -> Result { + self.get("/api/usage?range=today").await + } + + pub async fn startup_health(&self) -> Result { + self.get("/api/startup-health").await + } + + pub async fn quotas(&self) -> Result { + self.get("/api/provider-quotas").await + } + + pub async fn timeline(&self, query: &str) -> Result { + self.get(&format!("/api/usage/timeline?{query}")).await + } + + pub async fn stop(&self) -> Result { + self.request(Method::POST, "/api/stop").await + } + + async fn get(&self, path: &str) -> Result { + self.request(Method::GET, path).await + } + + async fn request(&self, method: Method, path: &str) -> Result { + let response = self.send(&method, path, None).await?; + if response.status() == StatusCode::UNAUTHORIZED { + let token = self.auth.token().ok_or(ProxyError::Unauthorized)?; + let response = self.send(&method, path, Some(token)).await?; + return decode(response).await; + } + decode(response).await + } + + async fn send( + &self, + method: &Method, + path: &str, + token: Option, + ) -> Result { + let mut request = self.client.request(method.clone(), self.endpoint.url(path)); + if let Some(value) = token { + request = request.header("X-OpenCodex-API-Key", value); + } + request.send().await.map_err(|error| { + if error.is_connect() { + ProxyError::Unreachable + } else { + ProxyError::Decode(error) + } + }) + } +} + +async fn decode(response: reqwest::Response) -> Result { + if response.status() == StatusCode::UNAUTHORIZED { + return Err(ProxyError::Unauthorized); + } + if !response.status().is_success() { + return Err(ProxyError::Http(response.status())); + } + response.json().await.map_err(ProxyError::Decode) +} diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs new file mode 100644 index 00000000000..434c1d62883 --- /dev/null +++ b/desktop/src-tauri/src/sidecar.rs @@ -0,0 +1,47 @@ +use crate::{discovery::ProxyEndpoint, proxy::ProxyClient}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_shell::{process::CommandChild, ShellExt}; +use tokio::time::{sleep, timeout, Duration, Instant}; + +pub async fn ensure_proxy( + app: &AppHandle, + proxy: &ProxyClient, + endpoint: ProxyEndpoint, +) -> Result, String> { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if matches!( + timeout(Duration::from_millis(250), proxy.is_alive()).await, + Ok(Ok(_)) + ) { + return Ok(None); + } + if Instant::now() >= deadline { + break; + } + sleep(Duration::from_millis(150)).await; + } + + let gui_dist = app + .path() + .resource_dir() + .map_err(|error| error.to_string())? + .join("gui") + .join("dist"); + let command = app + .shell() + .sidecar("ocx") + .map_err(|error| error.to_string())? + .args(["start", "--port", &endpoint.port.to_string()]) + .env("OPENCODEX_GUI_DIST", gui_dist); + let (_events, child) = command.spawn().map_err(|error| error.to_string())?; + + for _ in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + if proxy.is_alive().await.is_ok() { + return Ok(Some(child)); + } + } + let _ = child.kill(); + Err("the OpenCodex sidecar did not become healthy".into()) +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs new file mode 100644 index 00000000000..56f25506457 --- /dev/null +++ b/desktop/src-tauri/src/tray.rs @@ -0,0 +1,351 @@ +use crate::{formatting, proxy::ProxyClient, updater, widget, window}; +use serde_json::Value; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tauri::{ + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, Wry, +}; +use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_opener::OpenerExt; + +pub struct TrayState { + pub menu: Mutex>, + pub installing: AtomicBool, +} + +pub struct UpdateMenu { + check_updates: MenuItem, + install_update: MenuItem, +} + +impl Default for TrayState { + fn default() -> Self { + Self { + menu: Mutex::new(None), + installing: AtomicBool::new(false), + } + } +} + +pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { + let open = MenuItem::with_id(app, "open-dashboard", "Open Dashboard", true, None::<&str>)?; + let browser = MenuItem::with_id(app, "open-browser", "Open in Browser", true, None::<&str>)?; + let login = CheckMenuItem::with_id( + app, + "start-at-login", + "Start at Login", + true, + app.autolaunch().is_enabled().unwrap_or(false), + None::<&str>, + )?; + let spawned_by_us = app + .state::() + .spawned_by_us + .load(Ordering::Relaxed); + let stop = MenuItem::with_id(app, "stop-proxy", "Stop proxy", spawned_by_us, None::<&str>)?; + let stop_item = stop.clone(); + let check_updates = MenuItem::with_id( + app, + "check-updates", + "Check for Updates…", + true, + None::<&str>, + )?; + let install_update = + MenuItem::with_id(app, "install-update", "Install update", false, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items( + app, + &[ + &open, + &browser, + &PredefinedMenuItem::separator(app)?, + &login, + &stop, + &PredefinedMenuItem::separator(app)?, + &check_updates, + &install_update, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?; + if let Ok(mut state) = app.state::().menu.lock() { + *state = Some(UpdateMenu { + check_updates: check_updates.clone(), + install_update: install_update.clone(), + }); + } + + let tray = TrayIconBuilder::with_id("main") + .icon(icon()) + .icon_as_template(true) + .menu(&menu) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + if let Some(window) = tray.app_handle().get_webview_window("main") { + window::show(&window); + } + } + }) + .on_menu_event(move |app, event| match event.id().as_ref() { + "open-dashboard" => { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } + } + "open-browser" => { + let endpoint = app.state::().proxy.endpoint(); + let _ = app + .opener() + .open_url(format!("{}#/usage", endpoint.url("/")), None::); + } + "start-at-login" => { + let enabled = app.autolaunch().is_enabled().unwrap_or(false); + if enabled { + let _ = app.autolaunch().disable(); + } else { + let _ = app.autolaunch().enable(); + } + } + "stop-proxy" => { + if app + .state::() + .spawned_by_us + .load(Ordering::Relaxed) + { + let proxy = app.state::().proxy.clone(); + let app = app.clone(); + let stop_item = stop_item.clone(); + tauri::async_runtime::spawn(async move { + let stopped = proxy.stop().await.is_ok() || proxy.is_alive().await.is_err(); + if stopped { + app.state::().shutdown_child(); + let _ = stop_item.set_enabled(false); + } + }); + } + } + "check-updates" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + updater::check_and_show(&app).await; + }); + } + "install-update" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let update = app + .state::() + .0 + .lock() + .ok() + .and_then(|mut pending| pending.take()); + let Some(update) = update else { + return; + }; + let version = update.version.clone(); + let retry_update = update.clone(); + set_installing(&app, &version); + if let Err(error) = updater::install(&app, update).await { + if let Ok(mut pending) = + app.state::().0.lock() + { + *pending = Some(retry_update); + } + set_install_failed(&app, &version); + crate::logging::log_once("updater install failed", &error); + } + }); + } + "quit" => app.exit(0), + _ => {} + }) + .build(app)?; + + refresh_title(&tray, &proxy); + widget::refresh(&proxy); + let tray = tray.clone(); + tauri::async_runtime::spawn(async move { + let mut tick = 0; + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + refresh_title(&tray, &proxy); + tick += 1; + if tick % 5 == 0 { + widget::refresh(&proxy); + } + } + }); + Ok(()) +} + +pub fn show_update_available(app: &AppHandle, version: &str) { + if let Some(state) = app.try_state::() { + if let Ok(menu) = state.menu.lock() { + if let Some(menu) = menu.as_ref() { + let _ = menu.install_update.set_text(updater::update_label(version)); + let _ = menu.install_update.set_enabled(true); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.check_updates.set_text("Check for Updates…"); + } + } + } +} + +pub fn show_up_to_date(app: &AppHandle) { + if let Some(state) = app.try_state::() { + if let Ok(menu) = state.menu.lock() { + if let Some(menu) = menu.as_ref() { + let _ = menu + .check_updates + .set_text(format!("Up to date (v{})", env!("CARGO_PKG_VERSION"))); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.install_update.set_enabled(false); + } + } + } +} + +pub fn is_installing(app: &AppHandle) -> bool { + app.try_state::() + .is_some_and(|state| state.installing.load(Ordering::Acquire)) +} + +fn set_installing(app: &AppHandle, version: &str) { + if let Some(state) = app.try_state::() { + state.installing.store(true, Ordering::Release); + if let Ok(menu) = state.menu.lock() { + if let Some(menu) = menu.as_ref() { + let _ = menu + .install_update + .set_text(format!("Installing update v{version}…")); + let _ = menu.install_update.set_enabled(false); + let _ = menu.check_updates.set_enabled(false); + } + } + } +} + +fn set_install_failed(app: &AppHandle, version: &str) { + if let Some(state) = app.try_state::() { + state.installing.store(false, Ordering::Release); + } + show_update_available(app, version); +} + +fn refresh_title(tray: &tauri::tray::TrayIcon, proxy: &ProxyClient) { + let proxy = proxy.clone(); + let tray = tray.clone(); + tauri::async_runtime::spawn(async move { + let Ok(settings) = proxy.companion_settings().await else { + return; + }; + let Ok(usage) = proxy.usage_summary().await else { + return; + }; + let quotas = proxy.quotas().await.unwrap_or(Value::Null); + if let Some(title) = render_title(&settings, &usage, "as) { + let _ = tray.set_title(Some(&title)); + } + }); +} + +pub(crate) fn render_title(settings: &Value, usage: &Value, quotas: &Value) -> Option { + let metric = settings + .pointer("/settings/menuBarMetric") + .and_then(Value::as_str) + .unwrap_or("tokens"); + let summary = usage.get("summary").unwrap_or(usage); + let quota = quota_percent(quotas); + let value = match metric { + "requests" => formatting::count(summary.get("requests").and_then(Value::as_i64)), + "cost" => formatting::cost(summary.get("estimatedCostUsd").and_then(Value::as_f64)), + "quota" => format_percent(quota), + "none" => return None, + _ => formatting::tokens(summary.get("totalTokens").and_then(Value::as_i64)), + }; + let template = settings + .pointer("/settings/menuBarTemplate") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + let rendered = template + .map(|value| { + value + .replace( + "{requests}", + &formatting::count(summary.get("requests").and_then(Value::as_i64)), + ) + .replace( + "{totalTokens}", + &formatting::tokens(summary.get("totalTokens").and_then(Value::as_i64)), + ) + .replace( + "{costUsd}", + &formatting::cost(summary.get("estimatedCostUsd").and_then(Value::as_f64)), + ) + .replace( + "{inputTokens}", + &formatting::tokens(summary.get("inputTokens").and_then(Value::as_i64)), + ) + .replace( + "{outputTokens}", + &formatting::tokens(summary.get("outputTokens").and_then(Value::as_i64)), + ) + .replace("{quotaPercent}", &format_percent(quota)) + }) + .unwrap_or(value); + let rendered = rendered.trim(); + if rendered.is_empty() { + None + } else if rendered.chars().count() > 24 { + Some(format!( + "{}…", + rendered.chars().take(23).collect::() + )) + } else { + Some(rendered.to_owned()) + } +} + +fn quota_percent(value: &Value) -> Option { + let reports = value.get("reports")?.as_array()?; + let mut values = Vec::new(); + for report in reports { + let Some(quota) = report.get("quota") else { + continue; + }; + for key in ["weeklyPercent", "monthlyPercent", "fiveHourPercent"] { + if let Some(value) = quota.get(key).and_then(Value::as_f64) { + values.push(value); + } + } + if let Some(windows) = quota.get("customWindows").and_then(Value::as_array) { + values.extend( + windows + .iter() + .filter_map(|window| window.get("percent").and_then(Value::as_f64)), + ); + } + } + values.into_iter().reduce(f64::min) +} + +fn format_percent(value: Option) -> String { + value + .map(|value| format!("{}%", value.round() as i64)) + .unwrap_or_else(|| "—".into()) +} + +fn icon() -> tauri::image::Image<'static> { + tauri::image::Image::from_bytes(include_bytes!("../icons/tray/icon.png")) + .expect("valid tray icon") +} diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs new file mode 100644 index 00000000000..4638c1565d8 --- /dev/null +++ b/desktop/src-tauri/src/updater.rs @@ -0,0 +1,71 @@ +use crate::{logging, tray}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; +use tauri_plugin_updater::{Update, UpdaterExt}; + +pub struct PendingUpdate(pub Mutex>); + +pub async fn check(app: &AppHandle) -> Result, String> { + app.updater() + .map_err(|error| error.to_string())? + .check() + .await + .map_err(|error| error.to_string()) +} + +pub async fn install(app: &AppHandle, update: Update) -> Result<(), String> { + update + .download_and_install(|_, _| {}, || {}) + .await + .map_err(|error| error.to_string())?; + app.restart(); +} + +pub fn update_label(version: &str) -> String { + format!("Install update v{version}") +} + +pub fn start_background_checks(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + loop { + check_and_show(&app).await; + tokio::time::sleep(std::time::Duration::from_secs(6 * 60 * 60)).await; + } + }); +} + +pub async fn check_and_show(app: &AppHandle) { + if tray::is_installing(app) { + return; + } + match check(app).await { + Ok(Some(update)) => { + if tray::is_installing(app) { + return; + } + let version = update.version.clone(); + if let Ok(mut pending) = app.state::().0.lock() { + *pending = Some(update); + } + tray::show_update_available(app, &version); + } + Ok(None) => { + if let Ok(mut pending) = app.state::().0.lock() { + *pending = None; + } + tray::show_up_to_date(app); + } + Err(error) => logging::log_once("updater check failed", &error), + } +} + +#[cfg(test)] +mod tests { + use super::update_label; + + #[test] + fn formats_update_menu_label() { + assert_eq!(update_label("2.62.0"), "Install update v2.62.0"); + } +} diff --git a/desktop/src-tauri/src/widget.rs b/desktop/src-tauri/src/widget.rs new file mode 100644 index 00000000000..4db3005f034 --- /dev/null +++ b/desktop/src-tauri/src/widget.rs @@ -0,0 +1,495 @@ +#[cfg(target_os = "macos")] +mod macos { + use crate::{ + proxy::{ProxyClient, ProxyError}, + tray, + }; + use serde::Serialize; + use serde_json::{json, Value}; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use uuid::Uuid; + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Today { + requests: Option, + total_tokens: Option, + estimated_cost_usd: Option, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Quota { + provider_label: String, + window_label: String, + percent: Option, + reset_at: Option, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Series { + id: String, + points: Vec, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Chart { + start: f64, + bucket_seconds: i64, + style: String, + series: Vec, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Snapshot { + schema_version: i64, + state: String, + state_title: String, + detail: Option, + endpoint_display: String, + menu_title: Option, + today: Option, + quotas: Vec, + chart: Option, + last_updated: Option, + generated_at: f64, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ErrorKind { + Unreachable, + Unauthorized, + Http, + Decode, + } + + fn state_for_error( + kind: ErrorKind, + detail: Option, + ) -> (&'static str, &'static str, Option) { + match kind { + ErrorKind::Unreachable => ( + "unreachable", + "Stopped", + Some("The proxy is not running.".into()), + ), + ErrorKind::Unauthorized => ( + "unauthorized", + "Needs API key", + Some("This proxy requires an API key.".into()), + ), + ErrorKind::Http | ErrorKind::Decode => ("degraded", "Degraded", detail), + } + } + + fn proxy_error(error: &ProxyError) -> (ErrorKind, Option) { + match error { + ProxyError::Unreachable => (ErrorKind::Unreachable, None), + ProxyError::Unauthorized => (ErrorKind::Unauthorized, None), + ProxyError::Http(status) => (ErrorKind::Http, Some(format!("HTTP {status}"))), + ProxyError::Decode(error) => (ErrorKind::Decode, Some(error.to_string())), + } + } + + fn number(value: Option<&Value>) -> Option { + value.and_then(Value::as_f64) + } + + fn integer(value: Option<&Value>) -> Option { + value.and_then(Value::as_i64) + } + + fn reset_at(value: Option<&Value>) -> Option { + let value = number(value)?; + Some(if value >= 1_000_000_000_000.0 { + value / 1000.0 + } else { + value + }) + } + + fn quotas(value: &Value) -> Vec { + let Some(reports) = value.get("reports").and_then(Value::as_array) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for report in reports { + let provider_label = report + .get("label") + .or_else(|| report.get("provider")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + let Some(quota) = report.get("quota") else { + continue; + }; + let mut push = |percent: Option<&Value>, window_label: &str, reset: Option<&Value>| { + if percent.is_some() || reset.is_some() { + rows.push(Quota { + provider_label: provider_label.clone(), + window_label: window_label.to_owned(), + percent: number(percent), + reset_at: reset_at(reset), + }); + } + }; + push( + quota.get("fiveHourPercent"), + "5h", + quota.get("fiveHourResetAt"), + ); + push( + quota.get("weeklyPercent"), + "week", + quota.get("weeklyResetAt"), + ); + push( + quota.get("monthlyPercent"), + "month", + quota.get("monthlyResetAt"), + ); + if let Some(windows) = quota.get("customWindows").and_then(Value::as_array) { + for window in windows { + let label = window + .get("label") + .and_then(Value::as_str) + .unwrap_or("window"); + push(window.get("percent"), label, window.get("resetAt")); + } + } + } + rows + } + + fn chart(value: &Value, settings: &Value) -> Option { + let start = number(value.get("start"))?; + let bucket_seconds = integer(value.get("bucketSeconds"))?; + let settings = settings.get("settings").unwrap_or(settings); + let style = settings + .get("chartStyle") + .and_then(Value::as_str) + .unwrap_or("line") + .to_owned(); + let series = value + .get("series") + .and_then(Value::as_array)? + .iter() + .take(6) + .filter_map(|item| { + Some(Series { + id: item.get("id")?.as_str()?.to_owned(), + points: item + .get("points")? + .as_array()? + .iter() + .filter_map(Value::as_f64) + .collect(), + }) + }) + .collect(); + Some(Chart { + start, + bucket_seconds, + style, + series, + }) + } + + fn timeline_query(settings: &Value) -> String { + let settings = settings.get("settings").unwrap_or(settings); + let get = |key: &str, fallback: &str| { + settings + .get(key) + .and_then(Value::as_str) + .unwrap_or(fallback) + .to_owned() + }; + let hours = settings + .get("chartHours") + .and_then(Value::as_i64) + .unwrap_or(24); + let bucket_minutes = settings + .get("bucketMinutes") + .and_then(Value::as_i64) + .unwrap_or(60); + let metric = get("tokenMetric", "total"); + let aggregation = get("aggregation", "sum"); + let grouping = get("chartGrouping", "model"); + let mut query = format!( + "hours={hours}&bucketMinutes={bucket_minutes}&metric={metric}&aggregation={aggregation}&grouping={grouping}" + ); + if let Some(models) = settings.get("models").and_then(Value::as_array) { + let models = models + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(","); + if !models.is_empty() { + query.push_str("&models="); + query.push_str(&models); + } + } + query + } + + fn snapshot_path() -> PathBuf { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + home.join("Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json") + } + + fn now_seconds() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + } + + fn without_generated_at(snapshot: &Snapshot) -> Snapshot { + let mut snapshot = snapshot.clone(); + snapshot.generated_at = 0.0; + snapshot + } + + fn write_if_changed( + path: &std::path::Path, + previous: Option<&Snapshot>, + snapshot: &Snapshot, + ) -> std::io::Result { + if previous.map(without_generated_at).as_ref() == Some(&without_generated_at(snapshot)) { + return Ok(false); + } + let Some(directory) = path.parent() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "snapshot path has no parent", + )); + }; + fs::create_dir_all(directory)?; + let bytes = serde_json::to_vec(snapshot).map_err(std::io::Error::other)?; + let temporary = directory.join(format!(".snapshot-{}.tmp", Uuid::new_v4())); + fs::write(&temporary, bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + } + fs::rename(temporary, path)?; + Ok(true) + } + + fn make_snapshot( + proxy: &ProxyClient, + settings: &Value, + health: &Value, + today: Option<&Value>, + quota_value: Option<&Value>, + timeline_value: Option<&Value>, + ) -> Snapshot { + let endpoint = proxy.endpoint(); + let detail = { + let parts = [health.get("status"), health.get("protection")] + .into_iter() + .filter_map(|value| value.and_then(Value::as_str)) + .filter(|part| !part.is_empty() && *part != "none") + .collect::>(); + (!parts.is_empty()).then(|| parts.join(" · ")) + }; + let today_snapshot = today + .and_then(|value| value.get("summary").or(Some(value))) + .map(|summary| Today { + requests: integer(summary.get("requests")), + total_tokens: integer(summary.get("totalTokens")), + estimated_cost_usd: number(summary.get("estimatedCostUsd")), + }); + let quotas_value = quota_value.unwrap_or(&Value::Null); + let menu_title = tray::render_title(settings, today.unwrap_or(&Value::Null), quotas_value); + let chart = timeline_value.and_then(|value| chart(value, settings)); + Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail, + endpoint_display: format!("{}:{}", endpoint.host, endpoint.port), + menu_title, + today: today_snapshot, + quotas: quotas(quotas_value), + chart, + last_updated: timeline_value.map(|_| now_seconds()), + generated_at: now_seconds(), + } + } + + pub async fn write(proxy: ProxyClient) { + let health = match proxy.startup_health().await { + Ok(value) => value, + Err(error) => { + let (kind, detail) = proxy_error(&error); + let (state, state_title, detail) = state_for_error(kind, detail); + let snapshot = Snapshot { + schema_version: 1, + state: state.into(), + state_title: state_title.into(), + detail, + endpoint_display: format!( + "{}:{}", + proxy.endpoint().host, + proxy.endpoint().port + ), + menu_title: None, + today: None, + quotas: Vec::new(), + chart: None, + last_updated: None, + generated_at: now_seconds(), + }; + let path = snapshot_path(); + let previous = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) { + crate::logging::log_once("widget snapshot write failed", &error.to_string()); + } + crate::logging::log_once("widget snapshot health failed", state); + return; + } + }; + let settings = proxy + .companion_settings() + .await + .unwrap_or_else(|_| json!({ "settings": {} })); + let today = proxy.usage_today().await.ok(); + let quota_value = proxy.quotas().await.ok(); + let timeline_value = proxy.timeline(&timeline_query(&settings)).await.ok(); + let snapshot = make_snapshot( + &proxy, + &settings, + &health, + today.as_ref(), + quota_value.as_ref(), + timeline_value.as_ref(), + ); + let path = snapshot_path(); + let previous = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) { + crate::logging::log_once("widget snapshot write failed", &error.to_string()); + } + } + + pub fn refresh(proxy: &ProxyClient) { + let proxy = proxy.clone(); + tauri::async_runtime::spawn(async move { write(proxy).await }); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn serialization_uses_swift_field_names() { + let snapshot = Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail: Some("ok".into()), + endpoint_display: "127.0.0.1:10100".into(), + menu_title: Some("2K".into()), + today: Some(Today { + requests: Some(2), + total_tokens: Some(1234), + estimated_cost_usd: Some(0.12), + }), + quotas: vec![Quota { + provider_label: "OpenAI".into(), + window_label: "week".into(), + percent: Some(10.0), + reset_at: Some(1.0), + }], + chart: Some(Chart { + start: 1.0, + bucket_seconds: 3600, + style: "line".into(), + series: vec![Series { + id: "openai/gpt".into(), + points: vec![1.0, 2.0], + }], + }), + last_updated: Some(2.0), + generated_at: 3.0, + }; + assert_eq!( + serde_json::to_string(&snapshot).unwrap(), + r#"{"schemaVersion":1,"state":"running","stateTitle":"Running","detail":"ok","endpointDisplay":"127.0.0.1:10100","menuTitle":"2K","today":{"requests":2,"totalTokens":1234,"estimatedCostUsd":0.12},"quotas":[{"providerLabel":"OpenAI","windowLabel":"week","percent":10.0,"resetAt":1.0}],"chart":{"start":1.0,"bucketSeconds":3600,"style":"line","series":[{"id":"openai/gpt","points":[1.0,2.0]}]},"lastUpdated":2.0,"generatedAt":3.0}"# + ); + } + + #[test] + fn error_state_mapping_covers_four_kinds() { + assert_eq!( + state_for_error(ErrorKind::Unreachable, None).0, + "unreachable" + ); + assert_eq!( + state_for_error(ErrorKind::Unauthorized, None).0, + "unauthorized" + ); + assert_eq!( + state_for_error(ErrorKind::Http, Some("HTTP 500".into())).0, + "degraded" + ); + assert_eq!( + state_for_error(ErrorKind::Decode, Some("bad".into())).0, + "degraded" + ); + } + + #[test] + fn write_if_changed_ignores_generated_at() { + let path = std::env::temp_dir().join(format!("ocx-widget-{}.json", std::process::id())); + let snapshot = Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail: None, + endpoint_display: "127.0.0.1:10100".into(), + menu_title: None, + today: None, + quotas: Vec::new(), + chart: None, + last_updated: None, + generated_at: 1.0, + }; + assert!(write_if_changed(&path, None, &snapshot).unwrap()); + let mut changed = snapshot.clone(); + changed.generated_at = 2.0; + assert!(!write_if_changed(&path, Some(&snapshot), &changed).unwrap()); + let _ = fs::remove_file(path); + } + + #[test] + fn chart_series_are_truncated_to_six() { + let series = (0..8) + .map(|index| json!({ "id": index.to_string(), "points": [1] })) + .collect::>(); + let value = json!({ "start": 1, "bucketSeconds": 60, "series": series }); + let result = chart(&value, &json!({ "settings": { "chartStyle": "line" } })).unwrap(); + assert_eq!(result.series.len(), 6); + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) use macos::refresh; + +#[cfg(not(target_os = "macos"))] +pub(crate) fn refresh(_: &crate::proxy::ProxyClient) {} diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs new file mode 100644 index 00000000000..f9550247ae3 --- /dev/null +++ b/desktop/src-tauri/src/window.rs @@ -0,0 +1,88 @@ +use crate::{auth::Auth, discovery::ProxyEndpoint}; +use tauri::{AppHandle, Manager, Url, WebviewWindow, WindowEvent}; + +pub fn webview_user_agent() -> String { + let platform = if cfg!(target_os = "macos") { + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)" + } else if cfg!(target_os = "windows") { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + } else { + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)" + }; + format!("{platform} {}", Auth::user_agent()) +} + +pub fn configure(window: &WebviewWindow) { + let window_for_close = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window_for_close.hide(); + apply_tray_policy(window_for_close.app_handle(), false); + } + }); +} + +pub fn navigation_allowed(endpoint: ProxyEndpoint) -> impl Fn(&Url) -> bool { + move |url| { + if url.scheme() == "tauri" { + return true; + } + if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { + return url.port_or_known_default() == Some(endpoint.port); + } + if matches!(url.scheme(), "http" | "https") { + let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); + return false; + } + url.scheme() == "about" && url.as_str() == "about:blank" + } +} + +pub fn show(window: &WebviewWindow) { + let _ = window.show(); + let _ = window.set_focus(); + apply_tray_policy(window.app_handle(), true); +} + +pub fn hide(window: &WebviewWindow) { + let _ = window.hide(); + apply_tray_policy(window.app_handle(), false); +} + +#[cfg(target_os = "macos")] +fn apply_tray_policy(app: &AppHandle, visible: bool) { + let policy = if visible { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }; + let _ = app.set_dock_visibility(visible); + let _ = app.set_activation_policy(policy); +} + +#[cfg(not(target_os = "macos"))] +fn apply_tray_policy(_app: &AppHandle, _visible: bool) {} + +pub fn set_tray_policy(app: &AppHandle, visible: bool) { + apply_tray_policy(app, visible); +} + +#[cfg(test)] +mod tests { + use super::webview_user_agent; + + #[test] + fn webview_user_agent_marks_the_desktop_shell() { + let user_agent = webview_user_agent(); + assert!(user_agent.starts_with("Mozilla/5.0 ")); + assert!(user_agent.contains("OpenCodexDesktop/")); + if cfg!(target_os = "macos") { + assert!(user_agent.contains("(Macintosh; Intel Mac OS X 10_15_7)")); + } else if cfg!(target_os = "windows") { + assert!(user_agent.contains("(Windows NT 10.0; Win64; x64)")); + } else { + assert!(user_agent.contains("(X11; Linux x86_64)")); + } + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 00000000000..6af54b5e1c2 --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "OpenCodex", + "version": "2.61.0", + "identifier": "com.opencodex.desktop", + "build": { + "frontendDist": "../ui", + "devUrl": "http://localhost:1420" + }, + "app": { + "security": { + "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; script-src 'self'" + } + }, + "bundle": { + "active": true, + "targets": "all", + "createUpdaterArtifacts": true, + "externalBin": [ + "binaries/ocx" + ], + "resources": { + "resources/gui/dist": "gui/dist" + }, + "icon": [ + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png" + ], + "macOS": { + "minimumSystemVersion": "13.0", + "files": { + "PlugIns/OpenCodexWidget.appex": "widget/OpenCodexWidget.appex" + }, + "dmg": { + "appPosition": { + "x": 180, + "y": 170 + }, + "applicationFolderPosition": { + "x": 480, + "y": 170 + } + } + }, + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper" + }, + "wix": { + "language": "en-US" + } + }, + "linux": { + "deb": { + "depends": [] + }, + "appimage": { + "bundleMediaFramework": false + } + } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNzZCMDg0NkVCRUJGODEKUldTQnY3NXVoTEIyckhRUXJMOXJRUDR0aHQ2L3pLVHVweXFSc1lzS24vWDNiSUJ5MXJIZmpyb2sK", + "endpoints": [ + "https://github.com/lidge-jun/opencodex/releases/latest/download/latest.json" + ] + } + } +} diff --git a/desktop/ui/index.html b/desktop/ui/index.html new file mode 100644 index 00000000000..e4d5c9a7de0 --- /dev/null +++ b/desktop/ui/index.html @@ -0,0 +1,29 @@ + + + + + + OpenCodex + + + +

}
- t(`usage.companion.menu${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ menuBarMetric: value })} /> - t(`usage.companion.window${value}` as never)} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> + t(MENU_METRIC_KEYS[value])} onChange={value => updateSettings({ menuBarMetric: value })} /> + t(WINDOW_KEYS[value])} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> value === "line" ? t("usage.companion.styleLine") : t("usage.companion.styleStacked")} onChange={value => updateSettings({ chartStyle: value })} /> - t(`usage.companion.metric${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ tokenMetric: value })} /> + t(TOKEN_METRIC_KEYS[value])} onChange={value => updateSettings({ tokenMetric: value })} /> value === "model" ? t("usage.companion.groupModel") : t("usage.companion.groupAccount")} onChange={value => updateSettings({ chartGrouping: value })} />
{t("usage.companion.popoverSections")} - {([ - ["showToday", "today"], - ["showChart", "chart"], - ["showModels", "models"], - ["showCost", "cost"], - ["showAccounts", "accounts"], - ] as const).map(([key, label]) => ( + {SECTION_OPTIONS.map(([key, label]) => (
- {t(`usage.companion.section${label[0]!.toUpperCase()}${label.slice(1)}` as never)} - + {t(label)} +
))}
{t("usage.companion.advanced")}
- t(`usage.companion.aggregation${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ aggregation: value })} /> + t(AGGREGATION_KEYS[value])} onChange={value => updateSettings({ aggregation: value })} />
+
+ {t("claudeDesktop.mode.legend")} + {DESKTOP_MODES.map(mode => ( + + ))} + {modeDirty && {t("claudeDesktop.mode.switchNote")}} +
+ {/* Always mount the bar (pending strut when status is still cold) so a late /status response cannot insert a full row under the title and shove the lanes down. */}
{/* Desktop serving another profile outranks content drift: stale config that is - read still works, a config that is never read does not. */} + read still works, a config that is never read does not. First-party never + writes a Desktop profile, so that check only applies in gateway mode. */} {statusFailed && !status ? t("claudeDesktop.loadFail") @@ -475,14 +540,23 @@ export default function ClaudeDesktop({ ? t("claudeDesktop.loading") : !status.desiredEnabled ? t("claudeDesktop.status.disabled") - : status.activeProfile === false + : effectiveMode === "gateway" && status.activeProfile === false ? t("claudeDesktop.status.notActiveProfile") : status.stale ? t("claudeDesktop.status.stale") : status.applied - ? t("claudeDesktop.status.applied") + ? effectiveMode === "first-party" ? t("claudeDesktop.status.appliedFirstParty") : t("claudeDesktop.status.applied") : t("claudeDesktop.status.notApplied")} + {status?.firstParty && effectiveMode === "first-party" && status.desiredEnabled && ( + + {status.firstParty.interceptRunning + ? t("claudeDesktop.firstParty.proxyRunning", { port: status.firstParty.proxyPort }) + : status.firstParty.interceptEnabled + ? t("claudeDesktop.firstParty.proxyStopped", { port: status.firstParty.proxyPort }) + : t("claudeDesktop.firstParty.interceptDisabled")} + + )} {status?.health.lastRequestAt && ( {t("claudeDesktop.health.lastRequest")}:{" "} @@ -508,7 +582,7 @@ export default function ClaudeDesktop({ {pending === "save" ? t("claudeDesktop.saving") : t("common.save")}
diff --git a/gui/src/styles/claude-desktop-mode-picker.css b/gui/src/styles/claude-desktop-mode-picker.css new file mode 100644 index 00000000000..ee6db473520 --- /dev/null +++ b/gui/src/styles/claude-desktop-mode-picker.css @@ -0,0 +1,22 @@ +/* ── Desktop connection mode picker (first-party default / gateway opt-in) ── */ +.claude-mode-picker { + display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; + margin: 0 0 14px; padding: 10px 14px 12px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); +} +.claude-mode-picker legend { padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); } +.claude-mode-picker:disabled { opacity: 0.6; } +.claude-mode-option { + display: grid; grid-template-columns: auto 1fr; column-gap: 10px; align-items: start; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; +} +.claude-mode-option.active { border-color: var(--accent); } +.claude-mode-option input { margin-top: 3px; } +.claude-mode-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; } +.claude-mode-hint { grid-column: 2; margin-top: 4px; font-size: 12px; color: var(--muted); line-height: 1.4; } +.claude-mode-default, .claude-mode-current { + padding: 1px 6px; border-radius: var(--radius-xs); font-size: 10px; font-weight: 600; letter-spacing: 0.02em; +} +.claude-mode-default { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); } +.claude-mode-current { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } +.claude-mode-switch-note { grid-column: 1 / -1; font-size: 12px; color: var(--amber); } diff --git a/gui/tests/claude-desktop-mode-picker.test.tsx b/gui/tests/claude-desktop-mode-picker.test.tsx new file mode 100644 index 00000000000..68215092244 --- /dev/null +++ b/gui/tests/claude-desktop-mode-picker.test.tsx @@ -0,0 +1,261 @@ +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 ClaudeDesktop from "../src/pages/ClaudeDesktop"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +/** + * The connection-mode picker decides which of two mutually exclusive Desktop + * configurations the apply request asks for. Mounted tests because the + * failures that matter are wiring: the radio must follow /status, a changed + * selection must reach the POST body, and the gateway-only "not active + * profile" check must not leak into first-party status. + */ + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let requests: { url: string; init?: RequestInit }[] = []; + +const MODEL = { + route: "prov/opus-0", + label: "Opus Model", + available: true, + contextWindow: 200_000, + effortSupported: true, + assignment: { family: "opus", alias: "alias-opus" }, +}; + +function profilePayload() { + return { + profile: { + version: 1, + assignments: { [MODEL.route]: MODEL.assignment }, + defaults: { opus: MODEL.route, fable: null, sonnet: null, haiku: null }, + }, + models: [MODEL], + rendered: [], + port: 10100, + }; +} + +function statusPayload(overrides: Record = {}) { + return { + desiredEnabled: true, + applied: true, + appliedAt: null, + stale: false, + health: { lastRequestAt: null, requestCount: 0, errorCount: 0 }, + mode: "first-party", + firstParty: { + applied: true, + stale: false, + interceptEnabled: true, + interceptRunning: true, + proxyPort: 10200, + caCertPath: "/tmp/ocx/claude-intercept/ca.pem", + }, + ...overrides, + }; +} + +function installFetch(status: Record) { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + requests.push({ url: String(url), init }); + const path = String(url); + const body = path.includes("/status") + ? status + : path.endsWith("/apply") + ? { ok: true, mode: JSON.parse(String(init?.body ?? "{}")).mode } + : init?.method === "PUT" + ? { ok: true } + : profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); +} + +beforeEach(() => { + clearClientResourceStoresForTests(); + requests = []; + previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) 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; + installFetch(statusPayload()); + 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; + } + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount() { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); +} + +function radio(mode: "first-party" | "gateway"): HTMLInputElement { + const found = container.querySelector(`input[name="claude-desktop-mode"][value="${mode}"]`); + if (!found) throw new Error(`mode radio not found: ${mode}`); + return found as unknown as HTMLInputElement; +} + +function applyButton(): HTMLButtonElement { + const found = Array.from(container.querySelectorAll("button.btn-primary")) + .find(button => /apply/i.test(button.textContent ?? "")); + if (!found) throw new Error("apply button not found"); + return found as unknown as HTMLButtonElement; +} + +async function click(element: HTMLElement) { + await act(async () => { element.click(); }); +} + +test("a failed /status unlocks the picker on the default without claiming a current mode", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string) => { + const path = String(url); + if (path.includes("/status")) { + return { ok: false, status: 503, json: async () => ({ error: "down" }), text: async () => "down" } as unknown as Response; + } + const body = profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + + await mount(); + await act(async () => { await new Promise(r => setTimeout(r, 200)); }); + + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(false); + expect(radio("first-party").checked).toBe(true); + expect(radio("gateway").checked).toBe(false); + expect(container.querySelector(".claude-mode-current")).toBeNull(); + expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("Failed to load"); +}); + +test("the picker follows the effective mode reported by /status and shows the proxy port", async () => { + await mount(); + expect(radio("first-party").checked).toBe(true); + expect(radio("gateway").checked).toBe(false); + const firstPartyOption = radio("first-party").closest("label")!; + expect(firstPartyOption.querySelector(".claude-mode-default")).not.toBeNull(); + expect(firstPartyOption.querySelector(".claude-mode-current")).not.toBeNull(); + expect(container.querySelector(".claude-mode-switch-note")).toBeNull(); + + const bar = container.querySelector(".claude-status-bar")!; + expect(bar.className).toContain("applied"); + expect(bar.textContent ?? "").toContain("First-party: Code tab routed through the local proxy"); + expect(bar.textContent ?? "").toContain("127.0.0.1:10200"); + expect(applyButton().textContent).toBe("Save & apply"); +}); + +test("a stopped intercept proxy is surfaced in first-party mode", async () => { + installFetch(statusPayload({ + firstParty: { applied: true, stale: false, interceptEnabled: true, interceptRunning: false, proxyPort: 10200, caCertPath: "/tmp/ca.pem" }, + })); + await mount(); + expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("is not running"); +}); + +test("selecting the other mode flips the apply label and sends that mode in the POST body", async () => { + await mount(); + await act(async () => { + radio("gateway").click(); + }); + expect(radio("gateway").checked).toBe(true); + expect(container.querySelector(".claude-mode-switch-note")).not.toBeNull(); + expect(applyButton().textContent).toBe("Switch mode & apply"); + + await click(applyButton()); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + + const apply = requests.find(r => r.url.endsWith("/api/claude-desktop/apply")); + expect(apply).toBeDefined(); + expect(apply!.init?.method).toBe("POST"); + expect(JSON.parse(String(apply!.init?.body))).toEqual({ mode: "gateway" }); +}); + +test("the default apply keeps the effective mode when nothing was chosen", async () => { + installFetch(statusPayload({ mode: "gateway", activeProfile: true, firstParty: undefined })); + await mount(); + expect(radio("gateway").checked).toBe(true); + await click(applyButton()); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + const apply = requests.find(r => r.url.endsWith("/api/claude-desktop/apply")); + expect(JSON.parse(String(apply!.init?.body))).toEqual({ mode: "gateway" }); +}); + +test("activeProfile=false only demotes the status bar in gateway mode", async () => { + // First-party never writes a Desktop profile, so Desktop serving some other + // profile is irrelevant and must not paint the bar as not-applied. + installFetch(statusPayload({ activeProfile: false })); + await mount(); + expect(container.querySelector(".claude-status-bar")!.className).toContain("applied"); + expect(container.querySelector(".claude-status-bar")!.className).not.toContain("not-applied"); + + await act(async () => { root!.unmount(); root = null; }); + clearClientResourceStoresForTests(); + installFetch(statusPayload({ mode: "gateway", activeProfile: false, firstParty: undefined })); + await mount(); + expect(container.querySelector(".claude-status-bar")!.className).toContain("not-applied"); +}); + +test("no radio is checked and the picker is disabled until /status answers", async () => { + // A gateway install must never see the first-party default flash while /status is in flight. + let releaseStatus: () => void = () => {}; + const gate = new Promise(resolve => { releaseStatus = resolve; }); + const gatewayStatus = statusPayload({ mode: "gateway", activeProfile: true, firstParty: undefined }); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + const path = String(url); + if (path.includes("/status")) await gate; + const body = path.includes("/status") ? gatewayStatus : profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + await mount(); + expect(radio("first-party").checked).toBe(false); + expect(radio("gateway").checked).toBe(false); + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(true); + expect(container.querySelector(".claude-mode-current")).toBeNull(); + expect(container.querySelector(".claude-mode-switch-note")).toBeNull(); + + releaseStatus(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(radio("gateway").checked).toBe(true); + expect(radio("first-party").checked).toBe(false); + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(false); +}); + +test("an unknown mode in /status is rejected as malformed", async () => { + installFetch(statusPayload({ mode: "proxy" })); + await mount(); + expect(container.textContent ?? "").toContain("Failed to load Claude Desktop profile."); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dea3270ec8d..cc24f755ee1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1600,7 +1600,12 @@ "api-key-scope-images.test.ts": "server", "api-key-scope-audio.test.ts": "server", "api-key-scope-live.test.ts": "server", - "api-key-scope-alpha-search.test.ts": "server" + "api-key-scope-alpha-search.test.ts": "server", + "claude-intercept-local-ca.test.ts": "claude-integration", + "claude-intercept-proxy.test.ts": "claude-integration", + "claude-intercept-settings.test.ts": "claude-integration", + "claude-desktop-first-party.test.ts": "claude-integration", + "claude-intercept-integration.test.ts": "server" }, "migrated": [ "adapters", diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index ec50f765ab0..e7ffcfb72ba 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -473,6 +473,12 @@ export function removeDesktop3pStandardPivot( options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null; unlink?: (path: string) => void; lifecycleLockDeps?: ClientLifecycleLockDeps; + /** + * The desired-state guard below exists for OFF flows racing a concurrent enable. A + * mode switch (gateway → first-party) removes the profile while the integration stays + * ON on purpose, so the caller opts out of that guard. + */ + replaceWhileEnabled?: boolean; } = {}, ): Desktop3pRemovalResult { const libraryPath = resolveDesktop3pConfigLibraryPath(options); @@ -488,7 +494,7 @@ export function removeDesktop3pStandardPivot( } const latest = readConfigDiagnostics(); if (latest.source === "fallback") return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_config_invalid" }; - if (claudeDesktopIntegrationEnabled(latest.config)) { + if (!options.replaceWhileEnabled && claudeDesktopIntegrationEnabled(latest.config)) { const observed = inspectDesktop3pConfigLibrary(options); if (observed.kind === "not_installed" || observed.kind === "no_owned_state") { return { ok: true, changed: false, kind: "noop", libraryPath }; diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts new file mode 100644 index 00000000000..0946aafddd5 --- /dev/null +++ b/src/claude/desktop-first-party.ts @@ -0,0 +1,172 @@ +/** + * Claude Desktop first-party mode. + * + * Desktop has two ways to reach opencodex: + * + * - `first-party` (default): the app keeps its ordinary claude.ai login, Chat tab, connectors + * and remote control. Only the Claude Code process it spawns for the Code tab (and that + * process's subagents) is redirected, through the `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` env + * in `~/.claude/settings.json` (src/claude/intercept/settings.ts) and the server's intercept + * pair (src/claude/intercept/runtime.ts). Nothing is written under Desktop's config library. + * - `gateway`: the historical third-party deployment profile (src/claude/desktop-3p.ts). The + * whole app is switched to a gateway build; picker entries are opencodex aliases. + * + * The two are mutually exclusive on disk: applying one removes the other. The mode is persisted + * in `claudeCode.desktopMode`; installs that predate the field but already carry an applied + * gateway profile keep `gateway` until they explicitly re-apply, so an update never flips a + * working Desktop under the operator. + */ +import { getConfigDir } from "../config/paths"; +import type { OcxConfig } from "../types"; +import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca"; +import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime"; +import { + applyClaudeInterceptSettings, + buildClaudeInterceptEnv, + inspectClaudeInterceptSettings, + removeClaudeInterceptSettings, + type ClaudeInterceptEnv, + type ClaudeInterceptSettingsState, + type ClaudeInterceptSettingsWrite, +} from "./intercept/settings"; + +export const CLAUDE_DESKTOP_MODES = ["first-party", "gateway"] as const; +export type ClaudeDesktopMode = typeof CLAUDE_DESKTOP_MODES[number]; +export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "first-party"; + +export function isClaudeDesktopMode(value: unknown): value is ClaudeDesktopMode { + return typeof value === "string" && (CLAUDE_DESKTOP_MODES as readonly string[]).includes(value); +} + +type DesktopModeConfig = Pick; + +/** + * Effective Desktop mode. An explicit `claudeCode.desktopMode` wins; otherwise a persisted + * gateway apply marker (`desktopProfile.appliedFingerprint`) means a pre-existing gateway + * install and keeps `gateway`; everything else is the first-party default. + */ +export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDesktopMode { + const explicit = config.claudeCode?.desktopMode; + if (isClaudeDesktopMode(explicit)) return explicit; + if (config.claudeCode?.desktopProfile?.appliedFingerprint) return "gateway"; + return DEFAULT_CLAUDE_DESKTOP_MODE; +} + +/** + * Config mutation that records the applied Desktop mode. Switching to first-party also drops + * the gateway apply marker: the profile assignments stay for a later gateway apply, but a + * stale `appliedFingerprint` must not make `resolveClaudeDesktopMode` read `gateway` again + * should the explicit marker ever go missing. + */ +export function recordClaudeDesktopMode( + config: DesktopModeConfig, + mode: ClaudeDesktopMode, +): { changed: boolean; value: true } { + const claudeCode = config.claudeCode ?? {}; + const profile = claudeCode.desktopProfile; + const dropMarker = mode === "first-party" && profile !== undefined + && (profile.appliedFingerprint !== undefined || profile.appliedAt !== undefined); + if (claudeCode.desktopMode === mode && !dropMarker) return { changed: false, value: true }; + if (dropMarker) { + const { appliedFingerprint: _fingerprint, appliedAt: _at, ...rest } = profile; + config.claudeCode = { ...claudeCode, desktopMode: mode, desktopProfile: rest }; + } else { + config.claudeCode = { ...claudeCode, desktopMode: mode }; + } + return { changed: true, value: true }; +} + +/** + * Mode an *apply* without an explicit choice should use. The first-party default only holds + * where the intercept proxy actually runs; with it disabled (or on a client role) an implied + * first-party apply would point Claude Code at a proxy that never starts, so fall back to the + * gateway profile. An explicit `desktopMode: "first-party"` is still honoured (and refused + * later with `intercept_disabled`, which names the fix). + */ +export function resolveClaudeDesktopApplyMode( + config: Pick, +): ClaudeDesktopMode { + const resolved = resolveClaudeDesktopMode(config); + if (resolved === "gateway" || isClaudeDesktopMode(config.claudeCode?.desktopMode)) return resolved; + return claudeInterceptEnabled(config) ? "first-party" : "gateway"; +} + +export interface DesktopFirstPartyTarget { + proxyPort: number; + caCertPath: string; + env: ClaudeInterceptEnv; +} + +/** The settings env a first-party apply on this machine writes (CA is created on demand). */ +export function desktopFirstPartyTarget( + config: Pick, + opencodexConfigDir = getConfigDir(), +): DesktopFirstPartyTarget { + const proxyPort = claudeInterceptProxyPort(config, config.port ?? 10100); + const caCertPath = claudeInterceptCaCertPath(opencodexConfigDir); + return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath) }; +} + +export interface DesktopFirstPartyInspection { + /** False when the server will not run the intercept pair (client role, intercept disabled). */ + interceptEnabled: boolean; + proxyPort: number; + caCertPath: string; + settings: ClaudeInterceptSettingsState; + /** settings.json carries exactly the env the current config would write. */ + applied: boolean; + /** Ours, but for an older port/config directory. Re-apply refreshes it. */ + stale: boolean; +} + +export interface DesktopFirstPartyOptions { + opencodexConfigDir?: string; + claudeConfigDir?: string; +} + +export function inspectDesktopFirstParty( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): DesktopFirstPartyInspection { + const target = desktopFirstPartyTarget(config, options.opencodexConfigDir); + const settings = inspectClaudeInterceptSettings(target.env, options.claudeConfigDir); + return { + interceptEnabled: claudeInterceptEnabled(config), + proxyPort: target.proxyPort, + caCertPath: target.caCertPath, + settings, + applied: settings.kind === "applied", + stale: settings.kind === "stale", + }; +} + +export type DesktopFirstPartyApplyResult = + | { ok: true; changed: boolean; path: string; env: ClaudeInterceptEnv; proxyPort: number } + | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string }; + +/** + * Write the first-party env into Claude Code's settings. Creates the local CA first so the + * path we point `NODE_EXTRA_CA_CERTS` at exists before Claude Code ever reads it. + */ +export function applyDesktopFirstParty( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): DesktopFirstPartyApplyResult { + const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir(); + const target = desktopFirstPartyTarget(config, opencodexConfigDir); + if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" }; + try { + ensureLocalInterceptCa(opencodexConfigDir); + } catch { + return { ok: false, reason: "ca_unavailable", path: target.caCertPath }; + } + const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir); + if (!written.ok) return { ok: false, reason: written.reason, path: written.path }; + return { ok: true, changed: written.changed, path: written.path, env: target.env, proxyPort: target.proxyPort }; +} + +/** Remove the first-party env. Only values anchored on our CA path are touched. */ +export function removeDesktopFirstParty(options: DesktopFirstPartyOptions = {}): ClaudeInterceptSettingsWrite { + const caCertPath = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir()); + return removeClaudeInterceptSettings(caCertPath, options.claudeConfigDir); +} diff --git a/src/claude/intercept/connect-proxy.ts b/src/claude/intercept/connect-proxy.ts new file mode 100644 index 00000000000..86c9375513f --- /dev/null +++ b/src/claude/intercept/connect-proxy.ts @@ -0,0 +1,179 @@ +import { BlockList, createServer, connect, isIP, type Server, type Socket } from "node:net"; + +/** + * Loopback HTTP CONNECT proxy for Claude Code. + * + * Claude Code honours `HTTPS_PROXY` and opens `CONNECT :443` for every upstream. This + * proxy splices tunnels for the intercepted hosts onto the local TLS listener (which holds a + * leaf certificate for them) and blindly relays every other tunnel to its real destination, + * so telemetry, OAuth refresh and claude.ai traffic stay native and opaque to opencodex. + * + * Only CONNECT is served. Plain proxied HTTP requests are refused: Claude Code never sends + * them, and answering them would turn this socket into a generic forward proxy. + */ + +export const CLAUDE_INTERCEPT_HOSTS = ["api.anthropic.com"] as const; + +const MAX_HEAD_BYTES = 8 * 1024; +const HEAD_TIMEOUT_MS = 10_000; +const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000; + +export interface ConnectProxyOptions { + /** Loopback port of the TLS listener that terminates intercepted tunnels. */ + interceptPort: number; + /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */ + interceptHosts?: readonly string[]; + /** Test seam: dial the real destination for a blind tunnel. */ + dialUpstream?: (host: string, port: number) => Socket; +} + +export interface ConnectProxyHandle { + port: number; + close(): Promise; +} + +interface ConnectTarget { + host: string; + port: number; +} + +/** Parse `CONNECT host:port HTTP/1.1` from a request head; `null` for anything else. */ +export function parseConnectRequestLine(head: string): ConnectTarget | null { + const requestLine = head.split("\r\n", 1)[0] ?? ""; + const match = /^CONNECT\s+(\S+)\s+HTTP\/1\.[01]$/.exec(requestLine); + if (!match) return null; + const authority = match[1]!; + // Bracketed IPv6 (`[::1]:443`) and plain `host:port`. + const ipv6 = /^\[([^\]]+)\]:(\d{1,5})$/.exec(authority); + const hostPort = ipv6 ?? /^([^:]+):(\d{1,5})$/.exec(authority); + if (!hostPort) return null; + const port = Number(hostPort[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return { host: hostPort[1]!.toLowerCase().replace(/\.$/, ""), port }; +} + +// 127/8 and ::1, which `BlockList` also matches in IPv4-mapped form (`::ffff:127.0.0.1`, +// `::ffff:7f00:1`). The unspecified addresses dial the local host too. +const LOCAL_TARGETS = new BlockList(); +LOCAL_TARGETS.addSubnet("127.0.0.0", 8, "ipv4"); +LOCAL_TARGETS.addAddress("0.0.0.0", "ipv4"); +LOCAL_TARGETS.addAddress("::1", "ipv6"); +LOCAL_TARGETS.addAddress("::", "ipv6"); + +export function isLoopbackTarget(host: string): boolean { + if (host === "localhost" || host.endsWith(".localhost")) return true; + const family = isIP(host); + if (family !== 0) return LOCAL_TARGETS.check(host, family === 6 ? "ipv6" : "ipv4"); + // `127.1`, `0x7f000001`, `2130706433`: resolver shorthand for a loopback literal, not a name. + return /^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host); +} + +function respond(socket: Socket, status: number, reason: string): void { + if (socket.destroyed) return; + socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); +} + +function splice(client: Socket, upstream: Socket, pending: Uint8Array): void { + const teardown = () => { + client.destroy(); + upstream.destroy(); + }; + client.on("error", teardown); + upstream.on("error", teardown); + client.on("close", () => upstream.destroy()); + upstream.on("close", () => client.destroy()); + client.setTimeout(0); + client.setNoDelay(true); + upstream.setNoDelay(true); + if (pending.length > 0) upstream.write(pending); + client.pipe(upstream); + upstream.pipe(client); +} + +function handleConnection(socket: Socket, options: Required>): void { + let head: Buffer = Buffer.alloc(0); + socket.on("error", () => socket.destroy()); + socket.setTimeout(HEAD_TIMEOUT_MS, () => respond(socket, 408, "Request Timeout")); + + const onData = (chunk: Buffer) => { + head = head.length === 0 ? chunk : Buffer.concat([head, chunk]); + const end = head.indexOf("\r\n\r\n"); + if (end === -1) { + if (head.length > MAX_HEAD_BYTES) { + socket.off("data", onData); + respond(socket, 431, "Request Header Fields Too Large"); + } + return; + } + socket.off("data", onData); + socket.pause(); + const target = parseConnectRequestLine(head.subarray(0, end).toString("latin1")); + // Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello). + const pending = head.subarray(end + 4); + if (!target) { + respond(socket, 405, "Method Not Allowed"); + return; + } + if (isLoopbackTarget(target.host)) { + respond(socket, 403, "Forbidden"); + return; + } + const intercept = target.port === 443 && options.interceptHosts.includes(target.host); + const upstream = intercept + ? connect({ host: "127.0.0.1", port: options.interceptPort }) + : options.dialUpstream(target.host, target.port); + let established = false; + const connectTimer = setTimeout(() => { + if (!established) { + upstream.destroy(); + respond(socket, 504, "Gateway Timeout"); + } + }, UPSTREAM_CONNECT_TIMEOUT_MS); + upstream.once("error", () => { + clearTimeout(connectTimer); + if (!established) respond(socket, 502, "Bad Gateway"); + }); + upstream.once("connect", () => { + established = true; + clearTimeout(connectTimer); + if (socket.destroyed) { + upstream.destroy(); + return; + } + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + splice(socket, upstream, pending); + socket.resume(); + }); + }; + socket.on("data", onData); +} + +/** Bind the CONNECT proxy on 127.0.0.1. Rejects when the port is unavailable. */ +export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise { + const resolved = { + interceptPort: options.interceptPort, + interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS, + dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })), + }; + return new Promise((resolve, reject) => { + const server: Server = createServer(socket => handleConnection(socket, resolved)); + const sockets = new Set(); + server.on("connection", socket => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.once("error", reject); + server.listen({ port, host: "127.0.0.1", exclusive: true }, () => { + server.off("error", reject); + const address = server.address(); + const boundPort = address && typeof address === "object" ? address.port : port; + resolve({ + port: boundPort, + close: () => new Promise(done => { + for (const socket of sockets) socket.destroy(); + server.close(() => done()); + }), + }); + }); + }); +} diff --git a/src/claude/intercept/listener.ts b/src/claude/intercept/listener.ts new file mode 100644 index 00000000000..8b4cdbb1409 --- /dev/null +++ b/src/claude/intercept/listener.ts @@ -0,0 +1,122 @@ +import type { Server } from "bun"; +import type { PemKeyPair } from "./local-ca"; + +/** + * TLS listener that terminates intercepted `api.anthropic.com` tunnels. + * + * The CONNECT proxy splices a Claude Code tunnel onto this socket; the client then speaks + * HTTPS believing it reached Anthropic. Messages traffic is rewritten to a loopback URL and + * handed to the router's own request handler, which already knows how to route mapped models + * to providers and pass genuine Claude models through with the caller's subscription + * credential. Every other path (usage, feedback, model listings, …) is relayed verbatim to + * the real upstream so the client keeps behaving like a first-party install. + */ + +export const CLAUDE_INTERCEPT_UPSTREAM = "https://api.anthropic.com"; + +const INTERCEPTED_PATHS = new Set(["/v1/messages", "/v1/messages/count_tokens"]); + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "upgrade", "te", "trailer", + "proxy-authenticate", "proxy-authorization", "proxy-connection", "host", "content-length", + "accept-encoding", +]); + +// fetch() transparently decodes the body, so the encoding headers would describe bytes the +// client never sees. +const RESPONSE_STRIP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", +]); + +export function isClaudeInterceptedPath(pathname: string, method: string): boolean { + return method === "POST" && INTERCEPTED_PATHS.has(pathname); +} + +/** + * Rebuild the request as if the client had dialled the router directly on loopback: the + * admission and Host checks then take the same path a plain loopback bind always has. + */ +export function rewriteInterceptedRequest(req: Request, loopbackOrigin: string): Request { + const url = new URL(req.url); + const headers = new Headers(req.headers); + headers.set("host", loopbackOrigin.replace(/^https?:\/\//, "")); + return new Request(`${loopbackOrigin}${url.pathname}${url.search}`, { + method: req.method, + headers, + body: req.body, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); +} + +export function forwardHeadersForUpstream(source: Headers): Headers { + const headers = new Headers(); + source.forEach((value, name) => { + if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return headers; +} + +export async function relayToUpstream(req: Request, upstreamBase: string, fetchImpl: typeof fetch = fetch): Promise { + const url = new URL(req.url); + const target = `${upstreamBase.replace(/\/$/, "")}${url.pathname}${url.search}`; + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + let upstream: Response; + try { + upstream = await fetchImpl(target, { + method: req.method, + headers: forwardHeadersForUpstream(req.headers), + body: hasBody ? req.body : undefined, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); + } catch (error) { + return Response.json( + { type: "error", error: { type: "api_error", message: `intercept relay failed: ${error instanceof Error ? error.message : String(error)}` } }, + { status: 502 }, + ); + } + const headers = new Headers(); + upstream.headers.forEach((value, name) => { + if (!RESPONSE_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers }); +} + +export interface ClaudeInterceptListenerOptions { + leaf: PemKeyPair; + /** Router request handler; receives the loopback-rewritten request and the intercept server. */ + dispatch: (req: Request, server: Server) => Promise; + upstreamBase?: string; + maxRequestBodySize?: number; + idleTimeout?: number; + fetchImpl?: typeof fetch; + /** Test seam: bind a fixed port instead of an ephemeral one. */ + port?: number; +} + +/** Bind the intercept TLS listener on an ephemeral loopback port. */ +export function startClaudeInterceptListener(options: ClaudeInterceptListenerOptions): Server { + const upstreamBase = options.upstreamBase ?? CLAUDE_INTERCEPT_UPSTREAM; + let loopbackOrigin = ""; + const server = Bun.serve({ + port: options.port ?? 0, + hostname: "127.0.0.1", + tls: { cert: options.leaf.certPem, key: options.leaf.keyPem }, + idleTimeout: options.idleTimeout ?? 255, + ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}), + async fetch(req, requestServer) { + const url = new URL(req.url); + if (isClaudeInterceptedPath(url.pathname, req.method)) { + return options.dispatch(rewriteInterceptedRequest(req, loopbackOrigin), requestServer); + } + return relayToUpstream(req, upstreamBase, options.fetchImpl); + }, + }); + loopbackOrigin = `http://127.0.0.1:${server.port}`; + return server; +} diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts new file mode 100644 index 00000000000..d3baf4a6c5e --- /dev/null +++ b/src/claude/intercept/local-ca.ts @@ -0,0 +1,278 @@ +import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign, type KeyObject } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Local certificate authority for the Claude intercept listener. + * + * Claude Code opens `CONNECT api.anthropic.com:443` through `HTTPS_PROXY` and then expects a + * TLS server that presents a certificate for that name. This module mints that authority and + * its leaves with nothing but `node:crypto`: no OpenSSL shell-out, no third-party ASN.1 + * library. The CA is trusted by the Claude Code process alone via `NODE_EXTRA_CA_CERTS`; it is + * never installed into an OS trust store. + * + * Only the subset of X.509 needed for a v3 CA and a serverAuth leaf is encoded. ECDSA P-256 is + * used for both, which Node's TLS stack accepts without any additional configuration. + */ + +export const CLAUDE_INTERCEPT_CA_COMMON_NAME = "opencodex Claude Intercept CA"; +const ORGANIZATION = "opencodex"; +const CA_VALIDITY_DAYS = 3650; +const LEAF_VALIDITY_DAYS = 365; + +// ── DER encoding ──────────────────────────────────────────────────────────────── + +function derLength(length: number): Uint8Array { + if (length < 0x80) return Uint8Array.of(length); + const bytes: number[] = []; + let rest = length; + while (rest > 0) { + bytes.unshift(rest & 0xff); + rest >>= 8; + } + return Uint8Array.of(0x80 | bytes.length, ...bytes); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function tlv(tag: number, body: Uint8Array): Uint8Array { + return concat(Uint8Array.of(tag), derLength(body.length), body); +} + +const sequence = (...parts: Uint8Array[]) => tlv(0x30, concat(...parts)); +const set = (...parts: Uint8Array[]) => tlv(0x31, concat(...parts)); +const boolean = (value: boolean) => tlv(0x01, Uint8Array.of(value ? 0xff : 0x00)); +const octetString = (body: Uint8Array) => tlv(0x04, body); +const utf8String = (text: string) => tlv(0x0c, new TextEncoder().encode(text)); +const contextTag = (n: number, body: Uint8Array, constructed = true) => tlv((constructed ? 0xa0 : 0x80) | n, body); + +function integer(bytes: Uint8Array): Uint8Array { + return tlv(0x02, (bytes[0]! & 0x80) !== 0 ? concat(Uint8Array.of(0), bytes) : bytes); +} + +function bitString(bytes: Uint8Array, unusedBits = 0): Uint8Array { + return tlv(0x03, concat(Uint8Array.of(unusedBits), bytes)); +} + +function objectIdentifier(dotted: string): Uint8Array { + const arcs = dotted.split(".").map(Number); + const bytes: number[] = [arcs[0]! * 40 + arcs[1]!]; + for (const arc of arcs.slice(2)) { + const encoded: number[] = [arc & 0x7f]; + let rest = arc >> 7; + while (rest > 0) { + encoded.unshift((rest & 0x7f) | 0x80); + rest >>= 7; + } + bytes.push(...encoded); + } + return tlv(0x06, Uint8Array.from(bytes)); +} + +function utcTime(date: Date): Uint8Array { + const pad = (n: number) => String(n).padStart(2, "0"); + const text = `${pad(date.getUTCFullYear() % 100)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` + + `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`; + return tlv(0x17, new TextEncoder().encode(text)); +} + +const OID = { + commonName: "2.5.4.3", + organization: "2.5.4.10", + ecdsaWithSha256: "1.2.840.10045.4.3.2", + basicConstraints: "2.5.29.19", + keyUsage: "2.5.29.15", + subjectAltName: "2.5.29.17", + extendedKeyUsage: "2.5.29.37", + subjectKeyIdentifier: "2.5.29.14", + authorityKeyIdentifier: "2.5.29.35", + serverAuth: "1.3.6.1.5.5.7.3.1", +} as const; + +function distinguishedName(commonName: string): Uint8Array { + return sequence( + set(sequence(objectIdentifier(OID.organization), utf8String(ORGANIZATION))), + set(sequence(objectIdentifier(OID.commonName), utf8String(commonName))), + ); +} + +function extension(oid: string, critical: boolean, value: Uint8Array): Uint8Array { + return critical + ? sequence(objectIdentifier(oid), boolean(true), octetString(value)) + : sequence(objectIdentifier(oid), octetString(value)); +} + +function subjectPublicKeyInfo(key: KeyObject): Uint8Array { + return new Uint8Array(key.export({ type: "spki", format: "der" })); +} + +/** RFC 5280 permits any unique octet string; a truncated SHA-256 of the SPKI is stable and collision-safe. */ +function keyIdentifier(key: KeyObject): Uint8Array { + return new Uint8Array(createHash("sha256").update(subjectPublicKeyInfo(key)).digest()).subarray(0, 20); +} + +function randomSerial(): Uint8Array { + const serial = crypto.getRandomValues(new Uint8Array(16)); + serial[0]! &= 0x7f; + if (serial[0] === 0) serial[0] = 1; + return serial; +} + +function toPem(label: string, der: Uint8Array): string { + const base64 = Buffer.from(der).toString("base64").replace(/(.{64})/g, "$1\n").trimEnd(); + return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`; +} + +interface IssueOptions { + subject: Uint8Array; + issuer: Uint8Array; + subjectKey: KeyObject; + signingKey: KeyObject; + validityDays: number; + extensions: Uint8Array[]; +} + +function issueCertificate(options: IssueOptions): Uint8Array { + // Back-date slightly so a client whose clock trails ours by a few minutes still accepts it. + const notBefore = new Date(Date.now() - 5 * 60_000); + const notAfter = new Date(notBefore.getTime() + options.validityDays * 86_400_000); + const algorithm = sequence(objectIdentifier(OID.ecdsaWithSha256)); + const tbsCertificate = sequence( + contextTag(0, tlv(0x02, Uint8Array.of(2))), + integer(randomSerial()), + algorithm, + options.issuer, + sequence(utcTime(notBefore), utcTime(notAfter)), + options.subject, + subjectPublicKeyInfo(options.subjectKey), + contextTag(3, sequence(...options.extensions)), + ); + const signature = new Uint8Array(sign("sha256", tbsCertificate, { key: options.signingKey, dsaEncoding: "der" })); + return sequence(tbsCertificate, algorithm, bitString(signature)); +} + +// ── Authority + leaves ────────────────────────────────────────────────────────── + +export interface PemKeyPair { + certPem: string; + keyPem: string; +} + +export interface LocalInterceptCa extends PemKeyPair { + publicKey: KeyObject; + privateKey: KeyObject; +} + +export function createLocalInterceptCa(): LocalInterceptCa { + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const name = distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME); + const der = issueCertificate({ + subject: name, + issuer: name, + subjectKey: publicKey, + signingKey: privateKey, + validityDays: CA_VALIDITY_DAYS, + extensions: [ + extension(OID.basicConstraints, true, sequence(boolean(true), tlv(0x02, Uint8Array.of(0)))), + // keyCertSign | cRLSign + extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)), + extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))), + ], + }); + return { + certPem: toPem("CERTIFICATE", der), + keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string, + publicKey, + privateKey, + }; +} + +/** Issue a serverAuth leaf for `hosts` (first entry becomes the CN; all become SAN dNSNames). */ +export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair { + if (hosts.length === 0) throw new Error("intercept leaf requires at least one host"); + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const der = issueCertificate({ + subject: distinguishedName(hosts[0]!), + issuer: distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME), + subjectKey: publicKey, + signingKey: ca.privateKey, + validityDays: LEAF_VALIDITY_DAYS, + extensions: [ + extension(OID.basicConstraints, true, sequence()), + // digitalSignature + extension(OID.keyUsage, true, bitString(Uint8Array.of(0x80), 7)), + extension(OID.extendedKeyUsage, false, sequence(objectIdentifier(OID.serverAuth))), + extension(OID.subjectAltName, false, sequence( + ...hosts.map(host => contextTag(2, new TextEncoder().encode(host), false)), + )), + extension(OID.authorityKeyIdentifier, false, sequence(contextTag(0, keyIdentifier(ca.publicKey), false))), + ], + }); + return { + certPem: toPem("CERTIFICATE", der), + keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string, + }; +} + +// ── Persistence ───────────────────────────────────────────────────────────────── + +export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept"; +export const CLAUDE_INTERCEPT_CA_CERT_FILE = "ca.pem"; +const CA_KEY_FILE = "ca.key"; + +export function claudeInterceptStateDir(configDir: string): string { + return join(configDir, CLAUDE_INTERCEPT_STATE_DIR); +} + +/** Path Claude Code must see in `NODE_EXTRA_CA_CERTS`. Stable across restarts. */ +export function claudeInterceptCaCertPath(configDir: string): string { + return join(claudeInterceptStateDir(configDir), CLAUDE_INTERCEPT_CA_CERT_FILE); +} + +function writeFileAtomic(path: string, contents: string, mode: number): void { + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, contents, { mode }); + try { chmodSync(tmp, mode); } catch { /* best-effort on platforms without POSIX modes */ } + renameSync(tmp, path); +} + +function loadPersistedCa(dir: string): LocalInterceptCa | null { + const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE); + const keyPath = join(dir, CA_KEY_FILE); + if (!existsSync(certPath) || !existsSync(keyPath)) return null; + try { + const certPem = readFileSync(certPath, "utf8"); + const keyPem = readFileSync(keyPath, "utf8"); + const privateKey = createPrivateKey(keyPem); + const publicKey = createPublicKey(keyPem); + if (!certPem.includes("BEGIN CERTIFICATE")) return null; + return { certPem, keyPem, publicKey, privateKey }; + } catch { // no-excuse-ok: catch -- an unreadable or corrupt authority is regenerated below. + return null; + } +} + +/** + * Load the persisted authority under `/claude-intercept/`, minting one when absent + * or unreadable. The private key is written 0600; the certificate is world-readable because + * `NODE_EXTRA_CA_CERTS` only needs the public half. + */ +export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa { + const dir = claudeInterceptStateDir(configDir); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const existing = loadPersistedCa(dir); + if (existing) return existing; + const ca = createLocalInterceptCa(); + writeFileAtomic(join(dir, CA_KEY_FILE), ca.keyPem, 0o600); + writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644); + return ca; +} diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts new file mode 100644 index 00000000000..a1452117d8c --- /dev/null +++ b/src/claude/intercept/runtime.ts @@ -0,0 +1,98 @@ +import type { Server } from "bun"; +import type { OcxConfig } from "../../types"; +import { getConfigDir } from "../../config/paths"; +import { CLAUDE_INTERCEPT_HOSTS, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy"; +import { startClaudeInterceptListener } from "./listener"; +import { claudeInterceptCaCertPath, ensureLocalInterceptCa, issueLocalInterceptLeaf } from "./local-ca"; + +/** + * Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener). + * + * Started next to the public listener, torn down with it. The proxy port is derived from the + * public port unless configured, because Claude Code's `settings.json` must name a port that + * survives restarts; the TLS listener is ephemeral and only ever reached through the proxy. + */ + +export const CLAUDE_INTERCEPT_PORT_OFFSET = 100; + +export function claudeInterceptEnabled(config: Pick): boolean { + if (config.runtimeRole === "client") return false; + if (config.claudeCode?.enabled === false) return false; + return config.claudeCode?.intercept?.enabled !== false; +} + +export function claudeInterceptProxyPort(config: Pick, publicPort: number): number { + const configured = config.claudeCode?.intercept?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET; +} + +export interface ClaudeInterceptState { + proxyPort: number; + caCertPath: string; +} + +export interface ClaudeInterceptHandle extends ClaudeInterceptState { + listener: Server; + stop(): Promise; +} + +let activeState: ClaudeInterceptState | null = null; + +/** Live intercept endpoints, or `null` when the pair is not running in this process. */ +export function getClaudeInterceptState(): ClaudeInterceptState | null { + return activeState; +} + +export interface StartClaudeInterceptOptions { + config: OcxConfig; + /** Bound public port; the derived proxy port is offset from it. */ + publicPort: number; + /** + * Port the operator asked for. `0` (ephemeral) gives the derived proxy port no stable value + * to write into `settings.json`, so intercept stays off unless `intercept.port` is explicit. + */ + requestedPort?: number; + dispatch: (req: Request, server: Server) => Promise; + maxRequestBodySize?: number; + configDir?: string; +} + +/** + * Bind both halves. Resolves `null` when intercept is disabled. A bind failure is reported by + * rejecting; callers treat it as a degraded optional integration, never as a startup failure. + */ +export async function startClaudeIntercept(options: StartClaudeInterceptOptions): Promise | null> { + if (!claudeInterceptEnabled(options.config)) return null; + const explicitPort = typeof options.config.claudeCode?.intercept?.port === "number"; + if (options.requestedPort === 0 && !explicitPort) return null; + const configDir = options.configDir ?? getConfigDir(); + const ca = ensureLocalInterceptCa(configDir); + const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); + const listener = startClaudeInterceptListener({ + leaf, + dispatch: options.dispatch, + upstreamBase: options.config.claudeCode?.anthropicBaseUrl, + ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}), + }); + let proxy: ConnectProxyHandle; + try { + proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), { + interceptPort: listener.port!, + }); + } catch (error) { + await listener.stop(true); + throw error; + } + const state: ClaudeInterceptState = { proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir) }; + activeState = state; + return { + ...state, + listener, + stop: async () => { + if (activeState === state) activeState = null; + await proxy.close(); + await listener.stop(true); + }, + }; +} diff --git a/src/claude/intercept/settings.ts b/src/claude/intercept/settings.ts new file mode 100644 index 00000000000..5b291a7b401 --- /dev/null +++ b/src/claude/intercept/settings.ts @@ -0,0 +1,161 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { claudeConfigDir } from "../auth-detect"; + +/** + * Claude Code `settings.json` env block for intercept mode. + * + * Both the Claude Desktop Code tab and the standalone `claude` CLI read the user + * `~/.claude/settings.json` and export its `env` map into the process before any network + * call. Two keys make every Claude Code process route through the local CONNECT proxy while + * the app itself stays a first-party install: + * + * env.HTTPS_PROXY = http://127.0.0.1: + * env.NODE_EXTRA_CA_CERTS = /claude-intercept/ca.pem + * + * Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives + * under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names + * that file is treated as ours. A user's own proxy or CA setting is left alone. + */ + +export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const; +export type ClaudeInterceptManagedEnv = typeof CLAUDE_INTERCEPT_MANAGED_ENV[number]; + +export interface ClaudeInterceptEnv { + HTTPS_PROXY: string; + NODE_EXTRA_CA_CERTS: string; +} + +export function claudeInterceptProxyUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string): ClaudeInterceptEnv { + return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort), NODE_EXTRA_CA_CERTS: caCertPath }; +} + +export type ClaudeInterceptSettingsState = + | { kind: "absent" } + | { kind: "applied"; env: ClaudeInterceptEnv } + | { kind: "stale"; env: Partial } + | { kind: "foreign"; env: Partial> } + | { kind: "unreadable"; path: string }; + +type SettingsDoc = Record & { env?: Record }; + +function settingsPath(configDir: string): string { + return join(configDir, "settings.json"); +} + +function readSettings(path: string): { doc: SettingsDoc } | { error: "missing" | "unreadable" } { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { // no-excuse-ok: catch -- an absent settings file is the fresh-install state. + return { error: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unreadable" }; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { error: "unreadable" }; + return { doc: parsed as SettingsDoc }; + } catch { // no-excuse-ok: catch -- a corrupt settings file must be reported, not overwritten. + return { error: "unreadable" }; + } +} + +function envRecord(doc: SettingsDoc): Record { + return doc.env && typeof doc.env === "object" && !Array.isArray(doc.env) ? doc.env : {}; +} + +/** Loopback proxy URLs are the only shape opencodex ever writes. */ +export function isClaudeInterceptProxyUrl(value: unknown): value is string { + return typeof value === "string" && /^http:\/\/127\.0\.0\.1:\d{1,5}\/?$/.test(value.trim()); +} + +function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string { + return typeof value === "string" && value.trim() === ownedCaPath; +} + +/** Classify the current settings env against the values this router would write. */ +export function inspectClaudeInterceptSettings( + expected: ClaudeInterceptEnv, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsState { + const path = settingsPath(configDir); + const read = readSettings(path); + if ("error" in read) return read.error === "missing" ? { kind: "absent" } : { kind: "unreadable", path }; + const env = envRecord(read.doc); + const proxy = env.HTTPS_PROXY; + const ca = env.NODE_EXTRA_CA_CERTS; + if (proxy === undefined && ca === undefined) return { kind: "absent" }; + if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS) return { kind: "applied", env: expected }; + const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS); + const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy); + if (caOurs && proxyOurs) { + return { + kind: "stale", + env: { + ...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}), + ...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}), + }, + }; + } + return { + kind: "foreign", + env: { + ...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}), + ...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}), + }, + }; +} + +function writeSettings(path: string, doc: SettingsDoc): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8"); + renameSync(tmp, path); +} + +export type ClaudeInterceptSettingsWrite = + | { ok: true; changed: boolean; path: string } + | { ok: false; reason: "unreadable" | "foreign_env"; path: string }; + +/** + * Write the intercept env into `settings.json`. Refuses when a managed key already holds a + * value opencodex did not write (a user-configured corporate proxy, for instance). + */ +export function applyClaudeInterceptSettings( + env: ClaudeInterceptEnv, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsWrite { + const path = settingsPath(configDir); + const state = inspectClaudeInterceptSettings(env, configDir); + if (state.kind === "unreadable") return { ok: false, reason: "unreadable", path }; + if (state.kind === "foreign") return { ok: false, reason: "foreign_env", path }; + if (state.kind === "applied") return { ok: true, changed: false, path }; + const read = readSettings(path); + const doc: SettingsDoc = "doc" in read ? read.doc : {}; + doc.env = { ...envRecord(doc), ...env }; + writeSettings(path, doc); + return { ok: true, changed: true, path }; +} + +/** Remove the managed keys, but only the values opencodex owns. */ +export function removeClaudeInterceptSettings( + ownedCaPath: string, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsWrite { + const path = settingsPath(configDir); + const read = readSettings(path); + if ("error" in read) { + return read.error === "missing" ? { ok: true, changed: false, path } : { ok: false, reason: "unreadable", path }; + } + const env = envRecord(read.doc); + if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path }; + delete env.NODE_EXTRA_CA_CERTS; + if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY; + if (Object.keys(env).length === 0) delete read.doc.env; + else read.doc.env = env; + writeSettings(path, read.doc); + return { ok: true, changed: true, path }; +} diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 7ff04dfe4e4..92efabad4d0 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -15,13 +15,23 @@ import { type DesktopFamily, type DesktopProfile, } from "../claude/desktop-profile"; -import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { + applyDesktopFirstParty, + recordClaudeDesktopMode, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + type ClaudeDesktopMode, +} from "../claude/desktop-first-party"; import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy"; import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; import { CliUsageError, runtimeRequest, takeJsonFlag } from "./runtime-api"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import type { OcxConfig } from "../types"; + +const APPLY_FLAGS = ["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"]; function isFamily(value: string | undefined): value is DesktopFamily { return !!value && (DESKTOP_FAMILIES as readonly string[]).includes(value); @@ -29,7 +39,10 @@ function isFamily(value: string | undefined): value is DesktopFamily { function printDesktopHelp(): void { console.log(`Usage: - ocx claude desktop [apply] [--static|--hybrid|--discovery-only] + ocx claude desktop [apply] [--first-party | --gateway [--static|--hybrid|--discovery-only]] + --first-party (default) keep Desktop on claude.ai; route only the Code tab's Claude Code + through the local intercept proxy via ~/.claude/settings.json env + --gateway install the third-party gateway profile for the whole app ocx claude desktop show [--json] ocx claude desktop status [--json] ocx claude desktop move [--default] @@ -135,6 +148,104 @@ async function applyConnectedDesktopProfile( } } +/** Persist the operator's Desktop mode choice; a failed marker save never undoes the apply. */ +function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps): boolean { + try { + return withClientLifecycleSync(() => { + const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode)); + return outcome.status !== "unavailable"; + }, deps.lifecycleLockDeps); + } catch { + return false; + } +} + +export type DesktopApplyTarget = + | { kind: "first-party" } + | { kind: "gateway"; mode: Desktop3pConfigMode }; + +/** Parse `ocx claude desktop apply` flags into a target; legacy gateway shape flags imply --gateway. */ +/** + * Default mode when no flag is given: first-party wherever the local intercept proxy can + * run; a connected client (proxy lives on the hub) or a disabled intercept falls back to + * the gateway profile rather than pointing Claude Code at a proxy that does not exist. + */ +export function defaultDesktopApplyMode( + config: Pick, + connection: ClientConnectionState = readClientConnectionState(), +): ClaudeDesktopMode { + const resolved = resolveClaudeDesktopApplyMode(config); + if (resolved === "gateway") return resolved; + return connection.kind === "connected" ? "gateway" : "first-party"; +} + +export function parseDesktopApplyArgs( + flags: string[], + config: Pick, +): { target: DesktopApplyTarget } | { error: string } { + const shapeFlags = flags.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); + const wantsFirstParty = flags.includes("--first-party"); + const wantsGateway = flags.includes("--gateway") || shapeFlags.length > 0; + if (wantsFirstParty && wantsGateway) return { error: "--first-party cannot be combined with --gateway or gateway shape flags." }; + const unknown = flags.filter(arg => !["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"].includes(arg)); + if (unknown.length > 0) return { error: `알 수 없는 인자: ${unknown.join(" ")}` }; + const kind: ClaudeDesktopMode = wantsFirstParty ? "first-party" : wantsGateway ? "gateway" : defaultDesktopApplyMode(config); + if (kind === "first-party") return { target: { kind } }; + const parsedMode = parseDesktop3pModeArgs(shapeFlags); + if ("error" in parsedMode) return parsedMode; + return { target: { kind, mode: parsedMode.mode } }; +} + +/** + * First-party apply: settings.json env only. The intercept pair the env points at runs inside + * the hub process, so this is a local-hub operation — a connected client machine cannot reach + * a loopback proxy on the hub and is refused rather than left with a dead `HTTPS_PROXY`. + */ +async function applyFirstPartyDesktop( + deps: ApplyProfileDeps, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + try { assertNoClientDisconnectPending(); } catch { return { ok: false, path: "", reason: "client_disconnect_pending" }; } + const connection = readClientConnectionState(); + if (connection.kind === "connected") return { ok: false, path: "", reason: "first_party_requires_local_hub" }; + if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; + const config = loadConfig(); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return { ok: false, path: "", reason: desired.message }; + // First-party replaces gateway; the two must never be active together. + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = removeDesktop3pStandardPivot({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + return { ok: false, path: library.selectedProfilePath ?? "", reason: removed.kind === "cleanup_incomplete" ? "gateway_cleanup_incomplete" : `gateway_profile_active:${removed.reason ?? removed.kind}` }; + } + } + const applied = applyDesktopFirstParty(config); + if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason }; + const saved = saveDesktopMode("first-party", deps); + return { + ok: true, + path: applied.path, + ...(saved ? {} : { warning: "desktop mode marker was not saved" }), + }; +} + +export async function applyDesktop( + profile: DesktopProfile | undefined, + target: DesktopApplyTarget, + deps: ApplyProfileDeps = {}, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + if (target.kind === "first-party") return applyFirstPartyDesktop(deps); + // Gateway replaces first-party; the two must never be active together. + const removed = removeDesktopFirstParty(); + if (!removed.ok) return { ok: false, path: removed.path, reason: "first_party_settings_unreadable" }; + const result = await applyProfile(profile, target.mode, deps); + if (result.ok && !saveDesktopMode("gateway", deps)) { + return { ...result, warning: [result.warning, "desktop mode marker was not saved"].filter(Boolean).join(" ") }; + } + return result; +} + export async function applyProfile( profile: DesktopProfile | undefined, mode: Desktop3pConfigMode, @@ -222,24 +333,35 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf return 0; } - // Legacy mode flags remain apply aliases and are parsed before subcommands. - const legacyFlags = argv.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); - const applyInvocation = argv.length === 0 || command === "apply" || legacyFlags.length > 0; + // Mode flags remain apply aliases and are parsed before subcommands. + const applyFlags = argv.filter(arg => APPLY_FLAGS.includes(arg)); + const applyInvocation = argv.length === 0 || command === "apply" || applyFlags.length > 0; if (applyInvocation) { - const nonMode = argv.filter(arg => !["apply", "--static", "--hybrid", "--discovery-only"].includes(arg)); - if (nonMode.length > 0) { - console.error(`알 수 없는 인자: ${nonMode.join(" ")}`); - return 2; - } - const parsedMode = parseDesktop3pModeArgs(legacyFlags); - if ("error" in parsedMode) { console.error(parsedMode.error); return 2; } + const rest = argv.filter(arg => arg !== "apply"); + const parsedTarget = parseDesktopApplyArgs(rest, loadConfig()); + if ("error" in parsedTarget) { console.error(parsedTarget.error); return 2; } + const { target } = parsedTarget; try { - const result = await applyProfile(undefined, parsedMode.mode, deps); + const result = await applyDesktop(undefined, target, deps); if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); + if (result.reason?.startsWith("gateway_")) { + console.error("The gateway profile could not be removed safely, so first-party mode was not applied. Turn the integration off (dashboard toggle) and retry, or keep gateway with `ocx claude desktop apply --gateway`."); + } else if (result.reason === "foreign_env") { + console.error(`~/.claude/settings.json already sets HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${result.path}). Remove them or use --gateway.`); + } else if (result.reason === "intercept_disabled") { + console.error("First-party mode needs the Claude intercept proxy (claudeCode.intercept.enabled on a hub). Use --gateway instead."); + } else if (result.reason === "first_party_requires_local_hub") { + console.error("First-party mode runs on the hub machine only; on a connected client use --gateway."); + } return 1; } - console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`); + if (target.kind === "first-party") { + console.log(`Claude Desktop first-party 설정을 적용했습니다: ${result.path}`); + console.log("Desktop 앱 설정은 그대로이며, Code 탭의 Claude Code만 로컬 프록시를 거칩니다."); + } else { + console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`); + } // The write landed; only the bookkeeping marker did not. Saying nothing // would leave the saved-vs-applied display wrong with no explanation. if (result.warning) console.warn(`⚠️ ${result.warning}`); @@ -336,7 +458,8 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf } saveLocalDesktopProfile(reconciled, config.claudeCode?.desktopProfile, connection, deps); if (flags.includes("--apply")) { - const result = await applyProfile(reconciled, "static", deps); + // A routing profile is a gateway-mode artifact: importing with --apply installs it there. + const result = await applyDesktop(reconciled, { kind: "gateway", mode: "static" }, deps); if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; } if (result.warning) console.warn(`⚠️ ${result.warning}`); } diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index fe94a0b76e2..6a23b5a00c5 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -10,7 +10,13 @@ */ import { loadConfig } from "../config"; import { stripGrokConfig, type GrokInjectResult } from "../grok/inject"; -import { removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopMode, +} from "../claude/desktop-first-party"; import { claudeDesktopIntegrationEnabled, grokIntegrationEnabled, @@ -35,6 +41,10 @@ export interface EnsureDesiredIntegrationsDeps { opts?: { hostname?: string }, ) => Promise; removeDesktop3pStandardPivot: typeof removeDesktop3pStandardPivot; + removeDesktopFirstParty?: typeof removeDesktopFirstParty; + applyDesktopFirstParty?: typeof applyDesktopFirstParty; + inspectDesktopFirstParty?: typeof inspectDesktopFirstParty; + inspectDesktop3pConfigLibrary?: typeof inspectDesktop3pConfigLibrary; log?: (message: string) => void; error?: (message: string) => void; } @@ -113,16 +123,44 @@ export async function ensureGrokFenceMatchesDesired( } /** - * When Claude Desktop is durably OFF, clear any leftover owned gateway profile. - * ensure/update used to leave Claude-3p residue in place after a failed disable - * (drifted fingerprint), so the Integrations card kept looking applied/stale. + * When Claude Desktop is durably OFF, clear any leftover owned gateway profile and the + * first-party settings env. ensure/update used to leave Claude-3p residue in place after a + * failed disable (drifted fingerprint), so the Integrations card kept looking applied/stale. + * + * When it is ON in first-party mode, refresh a stale env (the intercept port follows the + * public port, so a port change would otherwise leave Claude Code pointed at a dead proxy). */ export function ensureClaudeDesktopMatchesDesired( deps: EnsureDesiredIntegrationsDeps = productionDeps, ): void { const config = deps.loadConfig(); const { log, error } = io(deps); - if (claudeDesktopIntegrationEnabled(config)) return; + if (claudeDesktopIntegrationEnabled(config)) { + if (resolveClaudeDesktopMode(config) !== "first-party") return; + const library = (deps.inspectDesktop3pConfigLibrary ?? inspectDesktop3pConfigLibrary)({ + appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, + }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + // The mode marker and the disk disagree. Replacing a live Desktop profile is an operator + // action, not something an update hook should do silently. + error("⚠️ Claude Desktop mode is first-party but a gateway profile is still applied; run `ocx claude desktop apply --first-party` (or `--gateway`) to reconcile."); + return; + } + const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); + if (!seen.stale) return; + const applied = (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config); + if (applied.ok && applied.changed) log(` + Claude Desktop first-party env refreshed (${applied.path})`); + else if (!applied.ok) error(`⚠️ Claude Desktop first-party env refresh skipped: ${applied.reason}.`); + return; + } + try { + const env = (deps.removeDesktopFirstParty ?? removeDesktopFirstParty)(); + if (env.ok && env.changed) log(" ↩️ Claude Desktop first-party env removed."); + else if (!env.ok) error(`⚠️ Claude Desktop first-party env cleanup skipped: ${env.reason} (${env.path}).`); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + error(`⚠️ Claude Desktop first-party env cleanup failed: ${detail}.`); + } try { const removed = deps.removeDesktop3pStandardPivot({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 18a45a68412..1711b8b314a 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -261,7 +261,24 @@ export const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown }; + const claude = claudeCode as { desktopProfile?: unknown; desktopMode?: unknown; intercept?: unknown }; + if (claude.desktopMode !== undefined && claude.desktopMode !== "first-party" && claude.desktopMode !== "gateway") { + ctx.addIssue({ code: "custom", path: ["claudeCode", "desktopMode"], message: "desktopMode must be \"first-party\" or \"gateway\"" }); + } + if (claude.intercept !== undefined) { + const intercept = claude.intercept; + if (!intercept || typeof intercept !== "object" || Array.isArray(intercept)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept"], message: "intercept must be an object" }); + } else { + const { enabled, port } = intercept as { enabled?: unknown; port?: unknown }; + if (enabled !== undefined && typeof enabled !== "boolean") { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "enabled"], message: "intercept.enabled must be a boolean" }); + } + if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "port"], message: "intercept.port must be an integer between 1 and 65535" }); + } + } + } if (claude.desktopProfile !== undefined) { try { parseDesktopProfile(claude.desktopProfile); diff --git a/src/server/index.ts b/src/server/index.ts index af20051d9eb..dc3462d638a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -69,11 +69,6 @@ import { runDevinProviderMergeStartupMigration } from "../providers/devin-provid import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; import type { StorageCleanupPolicy } from "../types"; -import { - MAX_CONFIGURABLE_INBOUND_BODY_BYTES, - MIN_CONFIGURABLE_INBOUND_BODY_BYTES, - resolveInboundBodyLimitBytes, -} from "./request-decompress"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; export { clearThreadAccountMap, @@ -203,7 +198,8 @@ import { } from "../lib/package-tree-integrity"; import { detectInstall } from "../update/index"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; -import { inspectStartupOwnership, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; +import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle"; +import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; import { acquireSpendLedgerServerLifecycle, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { @@ -627,26 +623,13 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe let server: Server; let loopbackServer: Server | null = null; let managementIngressServer: Server | null = null; - - // Resolved once, before any listener binds. The clamp is silent inside the resolver so it - // stays pure and per-request cheap; the operator is told here instead, once, because a - // config value that was quietly reduced is exactly the thing they would otherwise debug - // against the wrong limit. - const inboundBodyLimitBytes = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes); - const requestedInboundBodyLimit = config.maxInboundBodyBytes; - if (requestedInboundBodyLimit !== undefined - && requestedInboundBodyLimit > 0 - && requestedInboundBodyLimit !== inboundBodyLimitBytes) { - console.warn( - `[server] maxInboundBodyBytes=${requestedInboundBodyLimit} is outside the supported range ` - + `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; ` - + `using ${inboundBodyLimitBytes} bytes.`, - ); - } + const claudeIntercept = createClaudeInterceptLifecycle(); + const inboundBodyLimitBytes = resolveInboundBodyLimitWithWarning(config); function ingressForServer(requestServer: Server): ServerIngress { if (requestServer === loopbackServer) return "unauthenticated-loopback"; if (requestServer === managementIngressServer) return "hub-management"; + if (claudeIntercept.ownsListener(requestServer)) return "claude-intercept"; return "public"; } let backgroundLifecycle: ReturnType | null = null; @@ -748,6 +731,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe throw new AuxiliaryListenerBindError("hub.managementIngress", managementIngressPort, "127.0.0.1", error); } } + claudeIntercept.start({ + config, publicPort: server.port ?? listenPort, requestedPort: listenPort, maxRequestBodySize: inboundBodyLimitBytes, + dispatch: (req, requestServer) => serveOptions.fetch(req, requestServer), + }); } catch (error) { unregisterQuotaAutoRefresh?.(); userCostOverlayReconciler?.stop(); @@ -776,6 +763,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe ...(managementIngressRef ? [() => managementIngressRef.stop(closeActiveConnections)] : []), + () => claudeIntercept.stop(), async () => { await remoteWorkspaceShutdown?.(); }, async () => { try { diff --git a/src/server/index/claude-intercept-lifecycle.ts b/src/server/index/claude-intercept-lifecycle.ts new file mode 100644 index 00000000000..f49bc49b988 --- /dev/null +++ b/src/server/index/claude-intercept-lifecycle.ts @@ -0,0 +1,49 @@ +import type { Server } from "bun"; +import { + startClaudeIntercept, + type ClaudeInterceptHandle, + type StartClaudeInterceptOptions, +} from "../../claude/intercept/runtime"; + +/** + * Owns the Claude intercept pair (CONNECT proxy + TLS listener) on behalf of `startServer`. + * The pair is an optional integration: a bind failure degrades to a warning, never to a + * startup failure, because every other client keeps working without it. `startServer` stays + * synchronous, so the start is fire-and-forget and `stop()` awaits whatever it produced. + */ +export interface ClaudeInterceptLifecycle { + /** True once the TLS listener has bound and `requestServer` is it. */ + ownsListener(requestServer: Server): boolean; + start(options: StartClaudeInterceptOptions): void; + stop(): Promise; +} + +export function createClaudeInterceptLifecycle(): ClaudeInterceptLifecycle { + let listener: Server | null = null; + let pending: Promise | null> = Promise.resolve(null); + return { + ownsListener: requestServer => listener !== null && requestServer === listener, + start(options) { + const dispatch = options.dispatch; + pending = startClaudeIntercept({ + ...options, + dispatch: (req, requestServer) => { + listener ??= requestServer; + return dispatch(req, requestServer); + }, + }).then(handle => { + if (handle) { + listener = handle.listener; + console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`); + } + return handle; + }).catch((error: unknown) => { + console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`); + return null; + }); + }, + async stop() { + await (await pending)?.stop(); + }, + }; +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 053a5943919..4e4eab84bc7 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -24,6 +24,7 @@ import { } from "./startup-warnings"; import { remoteWorkspaceEnabled } from "../../remote-control/workspace-activation"; +import { isClaudeInterceptedPath } from "../../claude/intercept/listener"; import { markActivity } from "../../lib/sidecar-tracker"; import { knownModelIdsForProvider } from "../../router"; import { @@ -197,7 +198,16 @@ import { readyProtocolMetadata } from "../../remote/protocol"; import { modelCapabilityFields } from "../models-capabilities"; import { createWebsocketHandler } from "./websocket-handler"; -export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management"; +export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept"; + +/** + * Routes the Claude intercept TLS listener may reach. Everything else on that socket is relayed + * to the real upstream by the listener itself, so a request that lands here with another path + * is a bug, not a client — refuse it. + */ +export function claudeInterceptRouteAllowed(url: URL, req: Request): boolean { + return isClaudeInterceptedPath(url.pathname, req.method); +} export interface ServeOptionsContext { readonly server: Server; @@ -301,12 +311,24 @@ export function createServeOptions(ctx: ServeOptionsContext) { config, ); } + if (ingress === "claude-intercept" && !claudeInterceptRouteAllowed(codexCompatibleUrl(req.url), req)) { + return withCors( + formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), + req, + loopbackPolicy(), + ); + } // Auth and CORS decisions below read `policy`, not `config`. For the public listener the // two are the same object, so its behaviour is unchanged; for the loopback listener the // view substitutes 127.0.0.1 as the bind address, which is what routes it through the // same code path a plain loopback bind has always taken — Host-header check included. // Routing, provider selection and response bodies keep using `config`. - const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config; + // The Claude intercept listener is loopback by construction (the CONNECT proxy binds + // 127.0.0.1 and the request was rewritten onto a loopback origin), and its callers carry + // Anthropic credentials, not opencodex admission tokens — so it takes the loopback view too. + const policy: RequestPolicyView = ingress === "unauthenticated-loopback" || ingress === "claude-intercept" + ? loopbackPolicy() + : config; const url = codexCompatibleUrl(req.url); markActivity(`${req.method} ${url.pathname}`); diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index a29d3f6fcbf..76b9119f735 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -56,6 +56,11 @@ import { type PackageTreeIntegrityGuard, } from "../../lib/package-tree-integrity"; import type { LiveSidebandWebSocketFactory } from "./live-sideband"; +import { + MAX_CONFIGURABLE_INBOUND_BODY_BYTES, + MIN_CONFIGURABLE_INBOUND_BODY_BYTES, + resolveInboundBodyLimitBytes, +} from "../request-decompress"; // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the // "../src/server" import surface stable for tests/callers. @@ -205,6 +210,25 @@ export function warnAgentTaskRecoveryStartup(config: { console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); } +/** + * Resolved once, before any listener binds. The clamp is silent inside the resolver so it + * stays pure and per-request cheap; the operator is told here instead, once, because a + * config value that was quietly reduced is exactly the thing they would otherwise debug + * against the wrong limit. + */ +export function resolveInboundBodyLimitWithWarning(config: { maxInboundBodyBytes?: number }): number { + const limit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes); + const requested = config.maxInboundBodyBytes; + if (requested !== undefined && requested > 0 && requested !== limit) { + console.warn( + `[server] maxInboundBodyBytes=${requested} is outside the supported range ` + + `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; ` + + `using ${limit} bytes.`, + ); + } + return limit; +} + export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void { if (config.plaintextV2AgentMessages !== true) return; console.warn("⚠️ Experimental plaintext V2 agent messages are enabled."); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index ef553702087..91976c144fd 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -131,6 +131,17 @@ function persistDesktopProfileField( return { ok: true }; } +async function persistDesktopModeField( + config: OcxConfig, + desktopMode: "first-party" | "gateway", +): Promise<{ ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" }> { + const { recordClaudeDesktopMode } = await import("../../claude/desktop-first-party"); + const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); + if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; + recordClaudeDesktopMode(config, desktopMode); + return { ok: true }; +} + export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } { return { currentBytes: grokApplyFlight?.bytes ?? 0, @@ -1002,7 +1013,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { // #859: the CLI delegates here so the registry is built in the serving - // process. Accept an optional mode; default stays static for back-compat. + // process. `mode` selects the Desktop mode: `first-party` (default when the + // install has no gateway profile) or `gateway`; the legacy gateway shapes + // (static/hybrid/discovery) still select gateway with that library layout. let mode: "static" | "hybrid" | "discovery" = "static"; let parsed: unknown; try { @@ -1011,12 +1024,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + const { resolveClaudeDesktopApplyMode, applyDesktopFirstParty, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); const requested = (parsed as { mode?: unknown } | null)?.mode; + let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config); if (requested !== undefined) { if (requested === "static" || requested === "hybrid" || requested === "discovery") { mode = requested; + desktopMode = "gateway"; + } else if (requested === "gateway" || requested === "first-party") { + desktopMode = requested; } else { - return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400); + return jsonResponse({ error: "mode must be first-party, gateway, static, hybrid, or discovery" }, 400); } } // #859: a delegated CLI apply carries the profile it just saved — the @@ -1032,6 +1050,63 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } } + if (desktopMode === "first-party") { + const { setIntegrationEnabled } = await import("../../codex/desired-state"); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); + mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); + // First-party replaces gateway: the two must never be active together. + const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + let gatewayRemoved = false; + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + return jsonResponse({ + error: removed.kind === "cleanup_incomplete" + ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; first-party env was not applied." + : "The gateway profile could not be removed safely, so first-party mode was not applied.", + code: "claude_desktop_gateway_removal_failed", + reason: removed.reason ?? removed.kind, + ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}), + }, removed.kind === "cleanup_incomplete" ? 500 : 409); + } + gatewayRemoved = removed.changed; + } + const applied = applyDesktopFirstParty(config); + if (!applied.ok) { + const { firstPartyRefusalMessage } = await import("./native-integration-routes"); + return jsonResponse({ + error: firstPartyRefusalMessage(applied.reason, applied.path), + code: "claude_desktop_first_party_refused", + reason: applied.reason, + path: applied.path, + }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); + } + const modeSaved = await persistDesktopModeField(config, "first-party"); + return jsonResponse({ + ok: true, + mode: "first-party", + saved: modeSaved.ok, + applied: true, + changed: applied.changed || gatewayRemoved, + gatewayRemoved, + path: applied.path, + proxyPort: applied.proxyPort, + caCertPath: applied.env.NODE_EXTRA_CA_CERTS, + ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }), + }); + } + // Gateway mode replaces first-party: the two must never be active together. + const firstPartyRemoved = removeDesktopFirstParty(); + if (!firstPartyRemoved.ok) { + return jsonResponse({ + error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); the first-party proxy env could not be removed before switching to gateway mode.`, + code: "claude_desktop_first_party_refused", + reason: firstPartyRemoved.reason, + }, 500); + } const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const desired = setIntegrationEnabled("claude-desktop", true); if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); @@ -1083,6 +1158,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise nativeContextLimits(latest), ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); + const modeSaved = await persistDesktopModeField(config, "gateway"); + const modeWarning = modeSaved.ok ? undefined : `Claude Desktop was applied, but the gateway mode marker was not saved (${modeSaved.reason}).`; const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, @@ -1106,19 +1183,22 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise fingerprint: result.fingerprint, warning: [ `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`, + modeWarning, policyWarning, ].filter(Boolean).join(" "), }); } } + const warning = [modeWarning, policyWarning].filter(Boolean).join(" "); return jsonResponse({ ok: true, - saved: true, + mode: "gateway", + saved: modeSaved.ok, applied: true, path: result.path, fingerprint: result.fingerprint, policyState, - ...(policyWarning ? { warning: policyWarning } : {}), + ...(warning ? { warning } : {}), }); } catch (error) { rethrowManagementBodyTooLarge(error); @@ -1131,21 +1211,39 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise try { const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); + const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { getClaudeInterceptState } = await import("../../claude/intercept/runtime"); const persisted = loadConfig(); const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null; const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint }); const desiredEnabled = claudeDesktopIntegrationEnabled(persisted); - const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + const gatewayApplied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + // A gateway profile on disk is what Desktop actually runs, whatever the saved mode says. + const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted); + const firstPartySeen = inspectDesktopFirstParty(persisted); + const intercept = getClaudeInterceptState(); + const firstParty = { + settingsState: firstPartySeen.settings.kind, + applied: firstPartySeen.applied, + stale: firstPartySeen.stale, + interceptEnabled: firstPartySeen.interceptEnabled, + interceptRunning: intercept !== null, + proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort, + caCertPath: firstPartySeen.caCertPath, + }; + const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied; // "Needs update" is only meaningful while the integration is wanted. When the // durable switch is OFF, a leftover drifted profile is residue to clear — not // a stale apply the operator should refresh. - const stale = desiredEnabled && observed.kind === "gateway_drifted"; + const stale = desiredEnabled && (mode === "first-party" ? firstPartySeen.stale : observed.kind === "gateway_drifted"); const { getDesktopHealth } = await import("../../claude/desktop-health"); const { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, }); - const policy = claudeDesktopPolicyHealth(policyState); + // Managed-policy conflicts only matter for the gateway profile; first-party mode never + // touches Desktop's own configuration. + const policy = claudeDesktopPolicyHealth(mode === "first-party" ? "absent" : policyState); const health = { ...getDesktopHealth(), ok: policy.ok, @@ -1153,14 +1251,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise policy, }; const policyConflict = desiredEnabled && !policy.ok; - const baseDrift = desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe"; + const firstPartyResidue = mode === "gateway" && (firstPartySeen.applied || firstPartySeen.stale); + const baseDrift = desiredEnabled + ? !applied || stale || firstPartyResidue + : applied || gatewayApplied || firstPartySeen.applied || firstPartySeen.stale || observed.kind === "unsafe"; const driftReason = policyConflict ? policy.state === "present" ? "managed_policy_present" : "managed_policy_unknown" : desiredEnabled - ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : null - : applied ? "desired_off_gateway_selected" : null; + ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : firstPartyResidue ? "first_party_residue" : null + : gatewayApplied ? "desired_off_gateway_selected" : firstPartySeen.applied || firstPartySeen.stale ? "desired_off_first_party_env" : null; return jsonResponse({ desiredEnabled, + mode, + firstParty, installed: observed.kind !== "not_installed", observedKind: observed.kind, applied, diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 56158bd162e..ae20731777a 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -18,13 +18,21 @@ * 011 (Claude Code), 012 (Grok). */ import { join } from "node:path"; -import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; +import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../../config"; import { readRuntimePort } from "../../config/process-state"; import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog"; import { getCodexHome } from "../../codex/paths"; import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + recordClaudeDesktopMode, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + type ClaudeDesktopMode, +} from "../../claude/desktop-first-party"; import { projectGrokCatalog } from "../../grok/catalog"; import { injectGrokConfig, stripGrokConfig } from "../../grok/inject"; import { inspectGrokConfig } from "../../grok/inspect"; @@ -47,7 +55,9 @@ export type NativeRefusalReason = | "write_failed" | "metadata_unreadable" | "cleanup_incomplete" - | "desired_state_changed"; + | "desired_state_changed" + | "foreign_env" + | "intercept_disabled"; export interface NativeStatus { clientId: NativeIntegrationClientId; @@ -126,7 +136,7 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { const seen = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, }); - const state: NativeStatus["state"] = seen.kind === "gateway_ours" + const gatewayState: NativeStatus["state"] = seen.kind === "gateway_ours" ? "current" : seen.kind === "unsafe" || seen.kind === "broken" ? "unsafe" : "absent"; const disableBlocked = seen.kind === "unsafe" || seen.kind === "broken" || seen.kind === "foreign" @@ -135,6 +145,14 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { message: "Claude Desktop configuration cannot be changed safely.", } : null; + // First-party mode lives in Claude Code's settings.json, not in Desktop's library. A leftover + // gateway profile still counts as current (it is what Desktop is actually running). + const firstParty = resolveClaudeDesktopApplyMode(config) === "first-party" && gatewayState === "absent" + ? inspectDesktopFirstParty(config) + : null; + const state: NativeStatus["state"] = firstParty + ? firstParty.settings.kind === "unreadable" ? "unsafe" : firstParty.applied ? "current" : "absent" + : gatewayState; return { clientId: "claude-desktop", state, @@ -599,6 +617,28 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } } +export function firstPartyRefusalMessage( + reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env", + path: string, +): string { + switch (reason) { + case "intercept_disabled": + return "First-party mode needs the Claude intercept proxy, which is off in this configuration (claudeCode.intercept.enabled / client role). Use gateway mode instead."; + case "ca_unavailable": + return `The local intercept certificate could not be created (${path}).`; + case "unreadable": + return `Claude Code settings could not be parsed (${path}); nothing was written.`; + case "foreign_env": + return `Claude Code settings already set HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${path}); remove them first or use gateway mode.`; + } +} + +/** Record which Desktop mode is applied; `false` when the config file could not be updated. */ +function persistDesktopModeMarker(desktopMode: ClaudeDesktopMode): boolean { + const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); + return outcome.status !== "unavailable"; +} + let claudeDesktopToggleFlight: Promise | null = null; async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { @@ -626,6 +666,11 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise` and `NODE_EXTRA_CA_CERTS=/claude-intercept/ca.pem` + into the `env` block of Claude Code's `settings.json` (via `src/claude/intercept/settings.ts`), + creating the local authority first. Only the Claude Code process Desktop spawns for the Code tab + (and its subagents, and any standalone `claude` CLI) reads that env, so only their + `api.anthropic.com` traffic reaches the [Claude intercept pair](../runtime.md#claude-intercept-pair). +- **gateway**: the existing third-party profile written by `src/claude/desktop-3p.ts`; the whole + app switches to the local gateway. It is selected explicitly (`--gateway`, dashboard, or the + legacy `--static|--hybrid|--discovery-only` shape flags, which imply it). + +`resolveClaudeDesktopMode` returns the explicit `claudeCode.desktopMode` when set; otherwise a +persisted `desktopProfile.appliedFingerprint` (an existing gateway install) keeps `gateway`, and a +fresh install resolves to `first-party`. Updates therefore never flip a working gateway install +silently, while new installs land on first-party. `resolveClaudeDesktopApplyMode` narrows an +*implied* first-party to gateway where the intercept pair cannot run (client role or +`claudeCode.intercept.enabled: false`); an explicit `first-party` is refused with +`intercept_disabled` instead of being rewritten. + +Switching is ownership-aware in both directions: a gateway apply first removes the first-party env +(only values anchored on OpenCodex's own CA path); a first-party apply first pivots an owned +gateway profile (`gateway_ours`/`gateway_drifted`) back to standard through +`removeDesktop3pStandardPivot({ replaceWhileEnabled: true })` — the durable switch stays ON, so the +OFF-flow desired-state guard is bypassed on purpose — and fails (`claude_desktop_gateway_removal_failed`) +without writing the env if that pivot cannot complete. It refuses (`foreign_env`) when +`settings.json` already carries a proxy/CA value OpenCodex does not own. +Disabling the integration (native toggle, `ocx ensure` with the durable switch OFF) removes both the +gateway profile and the first-party env. With the switch ON in first-party mode, `ocx ensure` +re-applies a stale env (the proxy port follows the public port). + +Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; +`POST /api/claude-desktop/apply` with `mode` ∈ `first-party|gateway|static|hybrid|discovery` and +`GET /api/claude-desktop/status` (`mode`, `firstParty.{applied,stale,interceptEnabled,interceptRunning,proxyPort,caCertPath}`) +in `src/server/management/agent-settings-routes.ts`; the native toggle in +`src/server/management/native-integration-routes.ts` applies the resolved mode on enable. Managed +Windows policy health only applies in gateway mode, because first-party never touches Desktop's own +configuration. Ordinary Chat-tab traffic is out of scope for both modes. + ## Connected Claude Desktop profiles The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership); general status hands its resolved command to this check instead of probing the version twice. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 86881ab7bdc..de51d06c3c4 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -156,7 +156,7 @@ this document owns is which module holds which area and what invariant that area | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | -| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)). | +| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `first-party` default, `gateway`, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-first-party-and-gateway). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. | | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | diff --git a/structure/runtime.md b/structure/runtime.md index 9d7316cfc4e..ec03516b8cc 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -215,6 +215,27 @@ The hub-management socket is enabled only by `runtimeRole: "hub"` plus `hub.managementIngress.enabled`, always binds `127.0.0.1`, and default-denies everything except GUI, session bootstrap/exchange, and `/api/*`. +### Claude intercept pair + +At the end of the startup transaction, `startServer` also starts the optional Claude intercept pair +through `src/server/index/claude-intercept-lifecycle.ts` (fire-and-forget start, `ownsListener` for +the ingress decision, `stop` joined into the listener shutdown) from `src/claude/intercept/runtime.ts`: a loopback HTTP CONNECT proxy (`src/claude/intercept/connect-proxy.ts`) +and a loopback TLS listener (`src/claude/intercept/listener.ts`) that presents a leaf for +`api.anthropic.com` signed by a per-install authority (`src/claude/intercept/local-ca.ts`, persisted +under `/claude-intercept/` with a 0600 key; never installed into an OS trust store). +Claude Code reaches the pair through `HTTPS_PROXY` plus `NODE_EXTRA_CA_CERTS` in its settings env +(`src/claude/intercept/settings.ts`), so no `ANTHROPIC_BASE_URL` rewrite is involved and the client +still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.com:443` onto the TLS +listener, relays every other CONNECT target blind, and refuses plain proxied HTTP and loopback targets. +The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` onto a loopback +origin and dispatches them to the same route table under the `claude-intercept` ingress, which takes +the loopback request policy; every other path on the intercepted host is relayed verbatim to the +configured Anthropic upstream. The pair is on by default on a hub (`claudeCode.intercept.enabled`), +its proxy port defaults to the public port + 100 (`claudeCode.intercept.port`), and a bind failure +degrades to a startup warning rather than a startup failure; stop joins both sockets. A server asked +for an ephemeral public port (`startServer(0)`, the shape every in-process test fixture uses) has no +stable port to derive from, so the pair stays off unless `claudeCode.intercept.port` is explicit. + Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. A failed optional bind initiates rollback of every earlier socket; normal stop joins all bound diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts new file mode 100644 index 00000000000..7b391f98dae --- /dev/null +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + resolveClaudeDesktopMode, +} from "../../src/claude/desktop-first-party"; +import { parseDesktopApplyArgs } from "../../src/cli/claude-desktop"; +import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationEnabled } from "../../src/codex/desired-state"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +let library = ""; +let claudeDir = ""; +const previous: Record = {}; +const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; + +function config(extra: Partial = {}): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai", ...extra } as OcxConfig; +} + +function settings(): { env?: Record; [key: string]: unknown } { + return JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8")) as { env?: Record }; +} + +async function dispatch(path: string, init?: RequestInit, inputConfig: OcxConfig = config()) { + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + ...init, + headers: { Host: url.host, "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }), url, inputConfig, {}); + return { status: response!.status, body: await response!.json() as Record }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-desktop-1p-")); + library = join(root, "desktop-library"); + claudeDir = join(root, "claude"); + for (const key of ENV_KEYS) previous[key] = process.env[key]; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + writeFileSync(join(root, "config.json"), JSON.stringify(config())); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); +}); + +test("mode resolution: explicit wins, applied gateway fingerprint keeps gateway, otherwise first-party", () => { + expect(resolveClaudeDesktopMode(config())).toBe("first-party"); + expect(resolveClaudeDesktopMode(config({ claudeCode: { desktopMode: "gateway" } }))).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ + claudeCode: { desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" } }, + }))).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ + claudeCode: { + desktopMode: "first-party", + desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" }, + }, + }))).toBe("first-party"); +}); + +test("implied apply mode falls back to gateway where the intercept proxy cannot run", () => { + expect(resolveClaudeDesktopApplyMode(config())).toBe("first-party"); + expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client" }))).toBe("gateway"); + expect(resolveClaudeDesktopApplyMode(config({ claudeCode: { intercept: { enabled: false } } }))).toBe("gateway"); + // An explicit choice is never silently rewritten. + expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client", claudeCode: { desktopMode: "first-party" } }))).toBe("first-party"); +}); + +test("CLI apply flags: default first-party, legacy shape flags imply gateway, conflicts rejected", () => { + expect(parseDesktopApplyArgs([], config())).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs(["--first-party"], config())).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs(["--gateway"], config())).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect(parseDesktopApplyArgs(["--hybrid"], config())).toEqual({ target: { kind: "gateway", mode: "hybrid" } }); + expect(parseDesktopApplyArgs(["--gateway", "--discovery-only"], config())).toEqual({ target: { kind: "gateway", mode: "discovery" } }); + expect(parseDesktopApplyArgs([], config({ claudeCode: { desktopMode: "gateway" } }))).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect("error" in parseDesktopApplyArgs(["--first-party", "--gateway"], config())).toBe(true); + expect("error" in parseDesktopApplyArgs(["--first-party", "--static"], config())).toBe(true); + expect("error" in parseDesktopApplyArgs(["--bogus"], config())).toBe(true); +}); + +test("first-party apply writes only the proxy env, creates the CA, and removes cleanly", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ theme: "dark", env: { FOO: "bar" } })); + const applied = applyDesktopFirstParty(config()); + expect(applied.ok).toBe(true); + if (!applied.ok) return; + expect(applied.proxyPort).toBe(10200); + expect(existsSync(applied.env.NODE_EXTRA_CA_CERTS)).toBe(true); + expect(applied.env.NODE_EXTRA_CA_CERTS.startsWith(root)).toBe(true); + const written = settings(); + expect(written.theme).toBe("dark"); + expect(written.env).toEqual({ + FOO: "bar", + HTTPS_PROXY: "http://127.0.0.1:10200", + NODE_EXTRA_CA_CERTS: applied.env.NODE_EXTRA_CA_CERTS, + }); + expect(inspectDesktopFirstParty(config()).applied).toBe(true); + // Desktop's own library is untouched: first-party never installs a gateway profile. + expect(existsSync(library)).toBe(false); + + // A port change makes the env stale; re-apply refreshes it. + expect(inspectDesktopFirstParty(config({ port: 10300 })).stale).toBe(true); + const refreshed = applyDesktopFirstParty(config({ port: 10300 })); + expect(refreshed.ok && refreshed.changed).toBe(true); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); + + const removed = removeDesktopFirstParty(); + expect(removed).toMatchObject({ ok: true, changed: true }); + expect(settings()).toEqual({ theme: "dark", env: { FOO: "bar" } }); +}); + +test("first-party apply refuses foreign proxy env and disabled intercept", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } })); + expect(applyDesktopFirstParty(config())).toMatchObject({ ok: false, reason: "foreign_env" }); + expect(settings().env).toEqual({ HTTPS_PROXY: "http://corp-proxy:3128" }); + expect(removeDesktopFirstParty()).toMatchObject({ ok: true, changed: false }); + expect(applyDesktopFirstParty(config({ runtimeRole: "client" }))).toMatchObject({ ok: false, reason: "intercept_disabled" }); +}); + +test("POST /api/claude-desktop/apply defaults to first-party and gateway mode replaces it", async () => { + const first = await dispatch("/api/claude-desktop/apply", { method: "POST" }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ ok: true, mode: "first-party", applied: true, changed: true, proxyPort: 10200 }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("first-party"); + expect(saved.clientIntegrations?.["claude-desktop"]).not.toBe(false); + + const status = await dispatch("/api/claude-desktop/status"); + expect(status.body).toMatchObject({ + mode: "first-party", + applied: true, + stale: false, + drift: false, + firstParty: { applied: true, interceptEnabled: true, proxyPort: 10200 }, + }); + expect(status.body.health.ok).toBe(true); + + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + expect(gateway.status).toBe(200); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); + expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); + const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterGateway.claudeCode?.desktopMode).toBe("gateway"); + expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + + // Switching back replaces the gateway profile with the first-party env in one apply. + const back = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterGateway); + expect(back.status).toBe(200); + expect(back.body).toMatchObject({ ok: true, mode: "first-party", applied: true, gatewayRemoved: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const afterBack = await dispatch("/api/claude-desktop/status", {}, afterGateway); + expect(afterBack.body).toMatchObject({ mode: "first-party", applied: true, stale: false, drift: false, desiredEnabled: true }); + expect(["not_installed", "no_owned_state", "standard"]).toContain(afterBack.body.observedKind); + // The gateway apply marker goes with the profile: without the explicit mode field the + // saved config must still resolve to first-party, not to the gateway it just replaced. + const savedBack = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(savedBack.claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); + expect(savedBack.claudeCode?.desktopProfile?.appliedAt).toBeUndefined(); + expect(savedBack.claudeCode?.desktopProfile?.assignments).toBeDefined(); + expect(resolveClaudeDesktopMode({ claudeCode: { ...savedBack.claudeCode, desktopMode: undefined } })).toBe("first-party"); +}); + +test("native toggle: enable applies first-party by default and disable removes the env", async () => { + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + + const list = await dispatch("/api/native-integrations"); + const desktop = (list.body.clients as Array<{ clientId: string; state: string }>).find(client => client.clientId === "claude-desktop"); + expect(desktop?.state).toBe("current"); + + const disabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }); + expect(disabled.status).toBe(200); + expect(disabled.body).toMatchObject({ ok: true, changed: true, state: "absent", desiredEnabled: false }); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); +}); + +test("native toggle: enabling into explicit first-party pivots an applied gateway profile and saves the mode marker", async () => { + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + expect(gateway.status).toBe(200); + const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + // The operator chose first-party in config while the gateway profile is still on disk. + const chosen = { ...afterGateway, claudeCode: { ...afterGateway.claudeCode, desktopMode: "first-party" as const } }; + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("first-party"); + expect(saved.claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); + const status = await dispatch("/api/claude-desktop/status", {}, chosen); + expect(status.body).toMatchObject({ mode: "first-party", applied: true, drift: false }); + expect(["not_installed", "no_owned_state", "standard"]).toContain(status.body.observedKind); +}); + +test("native toggle: enabling into gateway saves the gateway mode marker like the apply route", async () => { + // No explicit mode: the disabled intercept is what implies gateway, so the saved marker + // can only come from the toggle itself. + const chosen = config({ claudeCode: { intercept: { enabled: false } } }); + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, state: "current", message: "Claude Desktop integration enabled." }); + expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("gateway"); + expect(resolveClaudeDesktopMode(saved)).toBe("gateway"); +}); + +test("ensure warns instead of touching a gateway profile that contradicts an explicit first-party marker", () => { + const logs: string[] = []; + const deps = { + loadConfig: () => config({ claudeCode: { desktopMode: "first-party" } }), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => { throw new Error("must not pivot from ensure"); }, + inspectDesktop3pConfigLibrary: () => ({ kind: "gateway_ours" as const, libraryPath: library, activeProfilePath: null, ownedFiles: [] }), + applyDesktopFirstParty: () => { throw new Error("must not apply over a live gateway"); }, + log: (message: string) => { logs.push(message); }, + error: (message: string) => { logs.push(message); }, + }; + ensureClaudeDesktopMatchesDesired(deps as unknown as Parameters[0]); + expect(logs.some(line => line.includes("gateway profile is still applied"))).toBe(true); +}); + +test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", () => { + const applied = applyDesktopFirstParty(config({ port: 10300 })); + expect(applied.ok).toBe(true); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); + + const logs: string[] = []; + const deps = { + loadConfig: () => config(), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => ({ ok: true as const, changed: false, kind: "noop" as const, libraryPath: library }), + log: (message: string) => { logs.push(message); }, + error: (message: string) => { logs.push(message); }, + }; + ensureClaudeDesktopMatchesDesired(deps); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + expect(logs.some(line => line.includes("first-party env refreshed"))).toBe(true); + + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + ensureClaudeDesktopMatchesDesired({ ...deps, loadConfig: () => config({ clientIntegrations: { "claude-desktop": false } }) }); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); + expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); +}); diff --git a/tests/claude-integration/claude-intercept-local-ca.test.ts b/tests/claude-integration/claude-intercept-local-ca.test.ts new file mode 100644 index 00000000000..474b25a1f7e --- /dev/null +++ b/tests/claude-integration/claude-intercept-local-ca.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { X509Certificate } from "node:crypto"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CLAUDE_INTERCEPT_CA_COMMON_NAME, + claudeInterceptCaCertPath, + claudeInterceptStateDir, + createLocalInterceptCa, + ensureLocalInterceptCa, + issueLocalInterceptLeaf, +} from "../../src/claude/intercept/local-ca"; + +function tmpConfigDir(): string { + return mkdtempSync(join(tmpdir(), "ocx-intercept-ca-")); +} + +test("CA certificate is a self-signed X.509 v3 authority", () => { + const ca = createLocalInterceptCa(); + const cert = new X509Certificate(ca.certPem); + expect(cert.ca).toBe(true); + expect(cert.subject).toContain(`CN=${CLAUDE_INTERCEPT_CA_COMMON_NAME}`); + expect(cert.issuer).toBe(cert.subject); + expect(cert.verify(ca.publicKey)).toBe(true); + expect(cert.checkIssued(cert)).toBe(true); + expect(new Date(cert.validFrom).getTime()).toBeLessThan(Date.now()); + expect(new Date(cert.validTo).getTime()).toBeGreaterThan(Date.now() + 365 * 24 * 3600 * 1000); +}); + +test("leaf is issued by the CA and names every requested host in SAN", () => { + const ca = createLocalInterceptCa(); + const leaf = issueLocalInterceptLeaf(ca, ["api.anthropic.com", "example.test"]); + const cert = new X509Certificate(leaf.certPem); + const caCert = new X509Certificate(ca.certPem); + expect(cert.ca).toBe(false); + expect(cert.checkIssued(caCert)).toBe(true); + expect(cert.verify(ca.publicKey)).toBe(true); + expect(cert.checkHost("api.anthropic.com")).toBe("api.anthropic.com"); + expect(cert.checkHost("example.test")).toBe("example.test"); + expect(cert.checkHost("other.example")).toBeUndefined(); + expect(cert.keyUsage).toContain("1.3.6.1.5.5.7.3.1"); + expect(leaf.keyPem).toContain("PRIVATE KEY"); +}); + +test("two CAs never share a serial or key", () => { + const a = createLocalInterceptCa(); + const b = createLocalInterceptCa(); + expect(new X509Certificate(a.certPem).serialNumber).not.toBe(new X509Certificate(b.certPem).serialNumber); + expect(a.keyPem).not.toBe(b.keyPem); +}); + +test("ensureLocalInterceptCa persists once and reloads the same authority", () => { + const configDir = tmpConfigDir(); + const first = ensureLocalInterceptCa(configDir); + const second = ensureLocalInterceptCa(configDir); + expect(second.certPem).toBe(first.certPem); + expect(second.keyPem).toBe(first.keyPem); + expect(readFileSync(claudeInterceptCaCertPath(configDir), "utf8")).toBe(first.certPem); + if (process.platform !== "win32") { + expect(statSync(join(claudeInterceptStateDir(configDir), "ca.key")).mode & 0o777).toBe(0o600); + expect(statSync(claudeInterceptCaCertPath(configDir)).mode & 0o777).toBe(0o644); + expect(statSync(claudeInterceptStateDir(configDir)).mode & 0o777).toBe(0o700); + } +}); + +test("a corrupt private key regenerates the authority instead of throwing", () => { + const configDir = tmpConfigDir(); + const first = ensureLocalInterceptCa(configDir); + writeFileSync(join(claudeInterceptStateDir(configDir), "ca.key"), "not a key\n"); + const regenerated = ensureLocalInterceptCa(configDir); + expect(regenerated.certPem).not.toBe(first.certPem); + expect(new X509Certificate(regenerated.certPem).ca).toBe(true); + expect(readFileSync(claudeInterceptCaCertPath(configDir), "utf8")).toBe(regenerated.certPem); +}); diff --git a/tests/claude-integration/claude-intercept-proxy.test.ts b/tests/claude-integration/claude-intercept-proxy.test.ts new file mode 100644 index 00000000000..c9b6db3fb33 --- /dev/null +++ b/tests/claude-integration/claude-intercept-proxy.test.ts @@ -0,0 +1,184 @@ +import { afterAll, expect, test } from "bun:test"; +import { connect, createServer } from "node:net"; +import { CLAUDE_INTERCEPT_HOSTS, isLoopbackTarget, parseConnectRequestLine, startConnectProxy, type ConnectProxyHandle } from "../../src/claude/intercept/connect-proxy"; +import { startClaudeInterceptListener, rewriteInterceptedRequest } from "../../src/claude/intercept/listener"; +import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; + +/** + * End-to-end shape of the intercept pair: a client that only knows `HTTPS_PROXY` and trusts the + * local CA reaches the router's Messages handler for `api.anthropic.com`, while every other + * CONNECT target is relayed blind. No real network: the "upstream" for blind tunnels is a local + * echo socket and the relay target for non-Messages paths is a local Bun server. + */ + +const cleanups: Array<() => Promise | void> = []; +afterAll(async () => { + for (const cleanup of cleanups.reverse()) await cleanup(); +}); + +test("parseConnectRequestLine accepts host:port and bracketed IPv6, rejects the rest", () => { + expect(parseConnectRequestLine("CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: x")).toEqual({ host: "api.anthropic.com", port: 443 }); + expect(parseConnectRequestLine("CONNECT API.Anthropic.COM.:443 HTTP/1.0")).toEqual({ host: "api.anthropic.com", port: 443 }); + expect(parseConnectRequestLine("CONNECT [::1]:8443 HTTP/1.1")).toEqual({ host: "::1", port: 8443 }); + expect(parseConnectRequestLine("GET http://example.com/ HTTP/1.1")).toBeNull(); + expect(parseConnectRequestLine("CONNECT example.com HTTP/1.1")).toBeNull(); + expect(parseConnectRequestLine("CONNECT example.com:99999 HTTP/1.1")).toBeNull(); +}); + +function rawRequest(port: number, payload: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port }, () => socket.write(payload)); + let out = ""; + socket.on("data", chunk => { out += chunk.toString("latin1"); }); + socket.on("end", () => resolve(out)); + socket.on("close", () => resolve(out)); + socket.on("error", reject); + }); +} + +async function startEchoUpstream(): Promise<{ port: number; close(): Promise }> { + const server = createServer(socket => socket.on("data", chunk => socket.write(`echo:${chunk.toString()}`))); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + return { port, close: () => new Promise(resolve => server.close(() => resolve())) }; +} + +async function startPair(): Promise<{ proxy: ConnectProxyHandle; ca: ReturnType; seen: Request[]; relayHits: string[] }> { + const ca = createLocalInterceptCa(); + const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); + const seen: Request[] = []; + const relayHits: string[] = []; + const fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + relayHits.push(`${req.method} ${new URL(req.url).pathname} host=${req.headers.get("host")}`); + return new Response(JSON.stringify({ relayed: true }), { headers: { "content-type": "application/json", "x-upstream": "fake" } }); + }, + }); + cleanups.push(() => fakeUpstream.stop(true)); + const listener = startClaudeInterceptListener({ + leaf, + upstreamBase: `http://127.0.0.1:${fakeUpstream.port}`, + dispatch: async req => { + seen.push(req); + const body = await req.text(); + return Response.json({ dispatched: true, url: req.url, host: req.headers.get("host"), body }); + }, + }); + cleanups.push(() => listener.stop(true)); + const echo = await startEchoUpstream(); + cleanups.push(echo.close); + const proxy = await startConnectProxy(0, { + interceptPort: listener.port!, + dialUpstream: (host, port) => { + expect(host).toBe("telemetry.example"); + expect(port).toBe(443); + return connect({ host: "127.0.0.1", port: echo.port }); + }, + }); + cleanups.push(proxy.close); + return { proxy, ca, seen, relayHits }; +} + +/** Speak HTTPS to `api.anthropic.com` through the CONNECT proxy, trusting only the local CA. */ +async function viaProxy(proxyPort: number, caPem: string, method: string, path: string, body?: string): Promise<{ status: number; headers: Headers; body: string }> { + const res = await fetch(`https://api.anthropic.com${path}`, { + method, + headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, + body, + proxy: `http://127.0.0.1:${proxyPort}`, + tls: { ca: caPem }, + }); + return { status: res.status, headers: res.headers, body: await res.text() }; +} + +test("CONNECT api.anthropic.com terminates TLS locally and dispatches Messages to the router", async () => { + const { proxy, ca, seen } = await startPair(); + const res = await viaProxy(proxy.port, ca.certPem, "POST", "/v1/messages?beta=true", JSON.stringify({ model: "claude-x" })); + expect(res.status).toBe(200); + const json = JSON.parse(res.body) as { dispatched: boolean; url: string; host: string; body: string }; + expect(json.dispatched).toBe(true); + expect(json.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1\/messages\?beta=true$/); + expect(json.host).toMatch(/^127\.0\.0\.1:\d+$/); + expect(json.body).toBe(JSON.stringify({ model: "claude-x" })); + expect(seen).toHaveLength(1); + expect(seen[0]!.headers.get("x-api-key")).toBe("sk-ant-test"); +}); + +test("non-Messages paths on the intercepted host are relayed to upstream, not dispatched", async () => { + const { proxy, ca, seen, relayHits } = await startPair(); + const res = await viaProxy(proxy.port, ca.certPem, "GET", "/v1/models?limit=1"); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ relayed: true }); + expect(res.headers.get("x-upstream")).toBe("fake"); + expect(relayHits).toEqual(["GET /v1/models host=127.0.0.1:" + relayHits[0]!.split(":").pop()]); + expect(seen).toHaveLength(0); +}); + +test("GET on /v1/messages is relayed rather than dispatched", async () => { + const { proxy, ca, seen, relayHits } = await startPair(); + await viaProxy(proxy.port, ca.certPem, "GET", "/v1/messages"); + expect(seen).toHaveLength(0); + expect(relayHits[0]).toStartWith("GET /v1/messages "); +}); + +test("other CONNECT targets are relayed blind, including pipelined bytes after the head", async () => { + const { proxy } = await startPair(); + const out = await new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port: proxy.port }, () => { + socket.write("CONNECT telemetry.example:443 HTTP/1.1\r\nHost: telemetry.example:443\r\n\r\nhello"); + }); + let buf = ""; + socket.on("data", chunk => { + buf += chunk.toString("latin1"); + if (buf.includes("echo:hello")) { socket.end(); resolve(buf); } + }); + socket.on("error", reject); + }); + expect(out.startsWith("HTTP/1.1 200 Connection Established\r\n\r\n")).toBe(true); + expect(out).toContain("echo:hello"); +}); + +test("plain proxied HTTP, loopback targets and oversized heads are refused", async () => { + const { proxy } = await startPair(); + expect(await rawRequest(proxy.port, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")).toStartWith("HTTP/1.1 405"); + expect(await rawRequest(proxy.port, "CONNECT 127.0.0.1:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, "CONNECT localhost:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, "CONNECT [::ffff:127.0.0.1]:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, `CONNECT a:443 HTTP/1.1\r\nX: ${"y".repeat(9000)}`)).toStartWith("HTTP/1.1 431"); +}); + +test("isLoopbackTarget covers mapped, unspecified and shorthand loopback literals", () => { + for (const host of ["localhost", "foo.localhost", "127.0.0.1", "127.255.0.9", "::1", "::ffff:127.0.0.1", "::ffff:7f00:1", "0.0.0.0", "::", "127.1", "0x7f000001", "2130706433"]) { + expect(isLoopbackTarget(host)).toBe(true); + } + for (const host of ["api.anthropic.com", "10.0.0.1", "::ffff:10.0.0.1", "2606:4700::1", "1.example"]) { + expect(isLoopbackTarget(host)).toBe(false); + } +}); + +test("a dead upstream yields 502 instead of a hung tunnel", async () => { + const dead = await startEchoUpstream(); + await dead.close(); + const proxy = await startConnectProxy(0, { + interceptPort: 1, + dialUpstream: () => connect({ host: "127.0.0.1", port: dead.port }), + }); + cleanups.push(proxy.close); + expect(await rawRequest(proxy.port, "CONNECT gone.example:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 502"); +}); + +test("rewriteInterceptedRequest moves the request onto the loopback origin and keeps path, query and headers", () => { + const original = new Request("https://api.anthropic.com/v1/messages?x=1", { + method: "POST", + headers: { "anthropic-version": "2023-06-01", host: "api.anthropic.com" }, + body: "{}", + }); + const rewritten = rewriteInterceptedRequest(original, "http://127.0.0.1:4567"); + expect(rewritten.url).toBe("http://127.0.0.1:4567/v1/messages?x=1"); + expect(rewritten.headers.get("host")).toBe("127.0.0.1:4567"); + expect(rewritten.headers.get("anthropic-version")).toBe("2023-06-01"); + expect(rewritten.method).toBe("POST"); +}); diff --git a/tests/claude-integration/claude-intercept-settings.test.ts b/tests/claude-integration/claude-intercept-settings.test.ts new file mode 100644 index 00000000000..81ad3cb6b9a --- /dev/null +++ b/tests/claude-integration/claude-intercept-settings.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyClaudeInterceptSettings, + buildClaudeInterceptEnv, + inspectClaudeInterceptSettings, + removeClaudeInterceptSettings, +} from "../../src/claude/intercept/settings"; +import { claudeInterceptEnabled, claudeInterceptProxyPort } from "../../src/claude/intercept/runtime"; +import { configSchema } from "../../src/config/schema/config-schema"; + +const CA = "/home/u/.opencodex/claude-intercept/ca.pem"; +const env = buildClaudeInterceptEnv(8846, CA); + +function dir(): string { + return mkdtempSync(join(tmpdir(), "ocx-intercept-settings-")); +} + +function readSettings(configDir: string): Record { + return JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8")) as Record; +} + +test("env block shape", () => { + expect(env).toEqual({ HTTPS_PROXY: "http://127.0.0.1:8846", NODE_EXTRA_CA_CERTS: CA }); +}); + +test("apply creates settings.json when absent and is idempotent", () => { + const configDir = dir(); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "absent" }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ env }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: false }); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "applied", env }); +}); + +test("apply preserves unrelated settings and env keys", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ model: "opus", env: { ANTHROPIC_MODEL: "gpt-x", FOO: "1" }, permissions: { allow: [] } })); + applyClaudeInterceptSettings(env, configDir); + expect(readSettings(configDir)).toEqual({ + model: "opus", + env: { ANTHROPIC_MODEL: "gpt-x", FOO: "1", ...env }, + permissions: { allow: [] }, + }); +}); + +test("a previous port is stale and gets rewritten; a foreign proxy is left alone", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:9000", NODE_EXTRA_CA_CERTS: CA } })); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "stale", env: { HTTPS_PROXY: "http://127.0.0.1:9000", NODE_EXTRA_CA_CERTS: CA } }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ env }); + + const foreign = dir(); + writeFileSync(join(foreign, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } })); + expect(inspectClaudeInterceptSettings(env, foreign).kind).toBe("foreign"); + expect(applyClaudeInterceptSettings(env, foreign)).toMatchObject({ ok: false, reason: "foreign_env" }); + expect(readSettings(foreign)).toEqual({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } }); + + // A loopback proxy with someone else's CA is not ours either. + const otherCa = dir(); + writeFileSync(join(otherCa, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } })); + expect(inspectClaudeInterceptSettings(env, otherCa).kind).toBe("foreign"); +}); + +test("remove deletes only owned values and drops an emptied env block", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ model: "opus", env: { ...env } })); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ model: "opus" }); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: true, changed: false }); + + const foreign = dir(); + writeFileSync(join(foreign, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } })); + expect(removeClaudeInterceptSettings(CA, foreign)).toMatchObject({ ok: true, changed: false }); + expect(readSettings(foreign)).toEqual({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } }); + + const missing = dir(); + expect(removeClaudeInterceptSettings(CA, missing)).toMatchObject({ ok: true, changed: false }); +}); + +test("corrupt settings.json is reported, never overwritten", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), "{ not json"); + expect(inspectClaudeInterceptSettings(env, configDir).kind).toBe("unreadable"); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: false, reason: "unreadable" }); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: false, reason: "unreadable" }); + expect(readFileSync(join(configDir, "settings.json"), "utf8")).toBe("{ not json"); +}); + +test("intercept is on by default for a hub, off for clients and when Claude Code is disabled", () => { + expect(claudeInterceptEnabled({})).toBe(true); + expect(claudeInterceptEnabled({ claudeCode: { intercept: { enabled: false } } })).toBe(false); + expect(claudeInterceptEnabled({ claudeCode: { enabled: false } })).toBe(false); + expect(claudeInterceptEnabled({ runtimeRole: "client" })).toBe(false); + expect(claudeInterceptProxyPort({}, 8746)).toBe(8846); + expect(claudeInterceptProxyPort({ claudeCode: { intercept: { port: 9100 } } }, 8746)).toBe(9100); + expect(claudeInterceptProxyPort({ claudeCode: { intercept: { port: 0 } } }, 8746)).toBe(8846); +}); + +test("claudeCode.intercept is validated by the config schema", () => { + const base = { + port: 0, defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }, + }; + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { enabled: false, port: 9100 } } }).success).toBe(true); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: "off" } }).success).toBe(false); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { enabled: "no" } } }).success).toBe(false); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { port: 70000 } } }).success).toBe(false); +}); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 60bc012fa25..751b838ee99 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -707,7 +707,11 @@ test("Claude Desktop profile GET, PUT and apply round-trip four-family assignmen const discovery = await fetch(new URL("/v1/models?flavor=anthropic", server.url)).then(r => r.json()) as { data: Array<{ id: string }> }; expect(discovery.data.some(model => model.id === alias)).toBe(true); - const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST" }); + const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "gateway" }), + }); expect(apply.status).toBe(200); const result = await apply.json() as { path: string; applied: boolean }; expect(result.applied).toBe(true); @@ -815,6 +819,14 @@ test("Claude Desktop apply validates the mode body", async () => { expect(badProfile.status).toBe(400); expect(loadConfig()).toEqual(beforeBadProfile); + const badGatewayProfile = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "gateway", profile: { version: 2 } }), + }); + expect(badGatewayProfile.status).toBe(400); + expect(loadConfig()).toEqual(beforeBadProfile); + const hybrid = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index bc579cdffbf..56c28ca34da 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -14,12 +14,16 @@ let root = ""; let library = ""; let previousHome: string | undefined; let previousLibrary: string | undefined; +let previousClaudeConfigDir: string | undefined; +// These tests pin the gateway (third-party profile) path; first-party is covered by +// tests/claude-integration/claude-desktop-first-party.test.ts. function config(): OcxConfig { return { port: 10100, providers: {}, defaultProvider: "openai", + claudeCode: { desktopMode: "gateway" }, } as OcxConfig; } @@ -84,8 +88,10 @@ beforeEach(() => { library = join(root, "desktop-library"); previousHome = process.env.OPENCODEX_HOME; previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); writeFileSync(join(root, "config.json"), JSON.stringify(config())); }); @@ -94,6 +100,8 @@ afterEach(() => { else process.env.OPENCODEX_HOME = previousHome; if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousLibrary; + if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; removeTreeWithRetry(root); }); @@ -237,7 +245,7 @@ test("auto-apply re-reads desired state after catalog fetch and skips a concurre assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, }; - const persisted = { ...config(), claudeCode: { desktopProfile: profile, injectAgents: false } }; + const persisted = { ...config(), claudeCode: { desktopMode: "gateway" as const, desktopProfile: profile, injectAgents: false } }; writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); writeFileSync(join(root, "config.json.bak"), JSON.stringify(persisted)); writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); @@ -302,7 +310,7 @@ test("explicit enable re-reads desired state after catalog fetch and skips a con }); test("explicit enable honors the Claude Desktop native-model opt-out", async () => { - const persisted = { ...config(), claudeCode: { desktopNativeModels: false } }; + const persisted = { ...config(), claudeCode: { desktopMode: "gateway" as const, desktopNativeModels: false } }; writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); let nativeSlugs: string[] | undefined; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e72ed9991e9..454504049af 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1432,5 +1432,10 @@ "api-key-scope-images.test.ts": "server", "api-key-scope-audio.test.ts": "server", "api-key-scope-live.test.ts": "server", - "api-key-scope-alpha-search.test.ts": "server" + "api-key-scope-alpha-search.test.ts": "server", + "claude-intercept-local-ca.test.ts": "claude-integration", + "claude-intercept-proxy.test.ts": "claude-integration", + "claude-intercept-settings.test.ts": "claude-integration", + "claude-desktop-first-party.test.ts": "claude-integration", + "claude-intercept-integration.test.ts": "server" } diff --git a/tests/lab/core-lab-boundary.test.ts b/tests/lab/core-lab-boundary.test.ts index 32a5a9758fc..3f928d27a83 100644 --- a/tests/lab/core-lab-boundary.test.ts +++ b/tests/lab/core-lab-boundary.test.ts @@ -1021,6 +1021,7 @@ describe("activation window stays synchronous", () => { "(...).then()": "Promise.then on the fire-and-forget `import('../codex/plan-from-token')` chain. then() registers a callback and returns immediately; the callback is a nested function this scan skips. Awaiting the import would already fail Guard 3.", "(...).catch()": "Promise.catch on that same dynamic-import chain. Same fire-and-forget: it cannot suspend startServer.", "backgroundLifecycle.scheduleStartupRun()": "src/server/background-lifecycle.ts owns this object method. The call site cannot resolve the declaration statically; scheduleStartupRun is declared `(): void` and is documented as never blocking listen.", + "claudeIntercept.start()": "Instance method on the lifecycle from createClaudeInterceptLifecycle in src/server/index/claude-intercept-lifecycle.ts. Declared `(options): void`; it stores the fire-and-forget promise from startClaudeIntercept and never awaits it, so the intercept pair binding late (or failing) cannot suspend startServer. Its stop() runs inside the async stop wrapper, which this scan skips.", "spendLedgerLifecycle.track()": "Instance method on the lifecycle from acquireSpendLedgerServerLifecycle in src/server/index/spend-ledger-lifecycle.ts, called on each listener as it is created. It binds the listener's stop, records a rollback closure and returns the same server; it is declared `(server: T): T` and contains no await. An `await spendLedgerLifecycle.track(...)` would already fail Guard 3. The lifecycle's release() is not here because it is called inside the async stop wrapper, which this scan skips as a nested function.", }; diff --git a/tests/server/claude-intercept-integration.test.ts b/tests/server/claude-intercept-integration.test.ts new file mode 100644 index 00000000000..6dc046fe1eb --- /dev/null +++ b/tests/server/claude-intercept-integration.test.ts @@ -0,0 +1,157 @@ +/** + * The Claude intercept pair wired into a real `startServer`: a client configured with nothing + * but `HTTPS_PROXY` and the local CA reaches the router's Messages handler under the loopback + * policy, while every other path on the intercepted host is relayed to the configured upstream + * and never touches the router's own routes. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { findAvailablePort } from "../../src/server/ports"; +import { claudeInterceptCaCertPath } from "../../src/claude/intercept/local-ca"; +import { getClaudeInterceptState } from "../../src/claude/intercept/runtime"; +import type { OcxConfig } from "../../src/types"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousHome = process.env.OPENCODEX_HOME; +let testDir = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-claude-intercept-")); + process.env.OPENCODEX_HOME = testDir; + process.env.OPENCODEX_API_AUTH_TOKEN = "public-secret"; +}); + +afterEach(() => { + if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir && existsSync(testDir)) removeTreeWithRetry(testDir); + testDir = ""; +}); + +async function waitForIntercept(): Promise>> { + for (let i = 0; i < 100; i++) { + const state = getClaudeInterceptState(); + if (state) return state; + await Bun.sleep(20); + } + throw new Error("intercept pair did not start"); +} + +test("Messages through CONNECT reach the router; other paths relay to the configured upstream", async () => { + const upstreamHits: string[] = []; + const fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + upstreamHits.push(`${req.method} ${new URL(req.url).pathname}`); + return Response.json({ upstream: true }); + }, + }); + const interceptPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: interceptPort }); + saveConfig({ + port: publicPort, + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + claudeCode: { + anthropicBaseUrl: `http://127.0.0.1:${fakeUpstream.port}`, + intercept: { port: interceptPort }, + }, + } as unknown as OcxConfig); + const server = startServer(publicPort); + try { + const state = await waitForIntercept(); + expect(state.proxyPort).toBe(interceptPort); + expect(state.caCertPath).toBe(claudeInterceptCaCertPath(testDir)); + const ca = readFileSync(state.caCertPath, "utf8"); + const proxy = `http://127.0.0.1:${state.proxyPort}`; + + // No opencodex admission token is sent: the intercept ingress takes the loopback policy, so + // the request is judged by the Messages handler (which fails on routing, since the test + // config has no usable provider credential) rather than refused at admission. + const messages = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + proxy, + tls: { ca }, + headers: { "content-type": "application/json", "anthropic-version": "2023-06-01", "x-api-key": "sk-ant-not-real" }, + body: JSON.stringify({ model: "no-such-model-for-intercept-test", max_tokens: 8, messages: [{ role: "user", content: "hi" }] }), + }); + const messagesBody = await messages.json() as { type: string; error: { message: string } }; + expect(messages.headers.get("content-type")).toContain("application/json"); + expect(messagesBody.type).toBe("error"); + expect(messagesBody.error.message).not.toContain("opencodex API key required"); + expect(messagesBody.error.message).not.toContain("Unknown endpoint"); + expect(upstreamHits).toEqual([]); + + // Anything else on the intercepted host is the client's own business with Anthropic. + const models = await fetch("https://api.anthropic.com/v1/models", { proxy, tls: { ca } }); + expect(await models.json()).toEqual({ upstream: true }); + const health = await fetch("https://api.anthropic.com/healthz", { proxy, tls: { ca } }); + expect(await health.json()).toEqual({ upstream: true }); + expect(upstreamHits).toEqual(["GET /v1/models", "GET /healthz"]); + } finally { + await server.stop(true); + fakeUpstream.stop(true); + } + expect(getClaudeInterceptState()).toBeNull(); +}, SERVER_BUDGET_MS); + +test("an ephemeral public port starts no proxy unless intercept.port is explicit", async () => { + const base = { + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + }; + saveConfig({ ...base, port: 10100 } as unknown as OcxConfig); + const implicit = startServer(0); + try { + await Bun.sleep(100); + expect(getClaudeInterceptState()).toBeNull(); + } finally { + await implicit.stop(true); + } + + const proxyPort = await findAvailablePort(0, "127.0.0.1"); + saveConfig({ ...base, port: 10100, claudeCode: { intercept: { port: proxyPort } } } as unknown as OcxConfig); + const explicit = startServer(0); + try { + const state = await waitForIntercept(); + expect(state.proxyPort).toBe(proxyPort); + } finally { + await explicit.stop(true); + } +}, SERVER_BUDGET_MS); + +test("intercept.enabled=false starts no proxy", async () => { + const publicPort = await findAvailablePort(0, "127.0.0.1"); + saveConfig({ + port: publicPort, + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + claudeCode: { intercept: { enabled: false } }, + } as unknown as OcxConfig); + const server = startServer(publicPort); + try { + await Bun.sleep(100); + expect(getClaudeInterceptState()).toBeNull(); + expect(existsSync(join(testDir, "claude-intercept"))).toBe(false); + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); From 61c87c6bb396ab9eb4615ce2c7d67af4da8fbd70 Mon Sep 17 00:00:00 2001 From: ahmedfrawelo Date: Sun, 20 Sep 2026 14:15:00 +0300 Subject: [PATCH 20/87] test(tray): prove hung-probe cleanup behaviorally (#5258) * test(tray): prove hung-probe cleanup behaviorally with a controlled fake CLI child Follow-up to #5184: replace the placement-blind gate substring assert and the comment-text assert with a win32-only lifecycle regression test. A PowerShell driver loads the real probe functions from windows-tray.ps1 via the AST, stages a real hung child through Start-StartupHealthProbe, backdates past the 30s timeout, and invokes the real Update-TrayState ticks: - Offline: tray stays offline yet terminates the hung child. - Online (fake /healthz): ticks kill the hung child and launch no replacement before the refresh interval (pid file proves 1 launch). Ablation: deleting the timeout-branch Kill() flips childTerminated to false, so the test goes red exactly when the behavior regresses. * test(tray): name the driver safety-net catch for hygiene * test(tray): harden the probe lifecycle driver and keep merge-time placement cover - Driver wraps the post-launch lifecycle in try/finally so a throw before the fake CLI writes its pid file cannot leak the 120s sleeper; the in-hand pid is stopped in finally, verdict evaluation stays before it. - Driver asserts the child is still alive after the settle wait, so an already-exited child cannot vacuous-pass through the exited-probe path. - Restore a lightweight platform-independent placement assert (timeout maintenance must precede the online-only UI branch, line-anchored to dodge the inline proxyPid conditional), because the win32-only behavioral test does not run on the PR-gated legs. * test(tray): anchor the placement check to the timeout branch itself --- .../windows-tray-probe-lifecycle-driver.ps1 | 178 ++++++++++++++++++ tests/windows/windows-tray.test.ts | 151 ++++++++++++++- 2 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 tests/helpers/windows-tray-probe-lifecycle-driver.ps1 diff --git a/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 b/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 new file mode 100644 index 00000000000..394b20a8352 --- /dev/null +++ b/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 @@ -0,0 +1,178 @@ +# Behavioral driver for the tray startup-health probe lifecycle. +# +# Loads the REAL probe functions out of src/tray/windows-tray.ps1 via the +# PowerShell AST (function definitions only - the top-level tray UI never runs), +# stubs the WinForms controls with plain objects, stages a real hung child +# process through the real Start-StartupHealthProbe, backdates its start past +# the real timeout, then invokes the real Update-TrayState ticks and reports +# observable process facts as JSON. +# +# Backdating the clock instead of sleeping 30s is deliberate: elapsed time is an +# input to the maintenance branch, not the logic under test. What IS under test +# is that the branch observes a timed-out child and terminates it. +# +# Scenarios: +# Offline - nothing answers /healthz, so the tray must stay offline AND still +# terminate the hung probe outside the online-only UI branch. +# Online - the caller serves a fake /healthz and points runtime-port.json at +# it, so the first tick launches the probe through the real cameOnline gate; +# later ticks must kill the hung child and must not stack a replacement +# before the refresh interval (the pid file proves launches == 1). + +param( + [Parameter(Mandatory = $true)][string]$TrayScriptPath, + [Parameter(Mandatory = $true)][string]$ChildEnginePath, + [Parameter(Mandatory = $true)][string]$HangChildPath, + [Parameter(Mandatory = $true)][string]$CodexHome, + [Parameter(Mandatory = $true)][string]$OpenCodexHome, + [Parameter(Mandatory = $true)][string]$ResultPath, + [ValidateSet("Offline", "Online")][string]$Scenario = "Offline" +) +$ErrorActionPreference = "Stop" + +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($TrayScriptPath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "tray script parse failed: $($parseErrors[0].Message)" } +$wanted = @( + "Write-ActionLog", + "ConvertTo-NativeArgument", + "Parse-StartupHealthText", + "Start-StartupHealthProbe", + "Complete-StartupHealthProbe", + "Read-ListenTarget", + "Read-JsonUrl", + "Update-TrayState" +) +$definitions = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) +$loaded = @() +foreach ($fn in $definitions) { + if ($wanted -contains $fn.Name) { + # Dot-source the full definition extent: several probe functions declare + # parentheses-style params (function F([string]$X) {...}), which live on + # the definition AST rather than the body. Installing only the body would + # silently drop those params ($X binds $null and the call misbehaves), + # while installing the raw extent as a body would make every call a + # silent no-op that merely redefines the function. Dot-sourcing defines + # each function exactly as the tray script declares it. + . ([ScriptBlock]::Create($fn.Extent.Text)) + $loaded += $fn.Name + } +} +$missing = @($wanted | Where-Object { $loaded -notcontains $_ }) +if ($missing.Count -gt 0) { throw "tray script is missing functions: $($missing -join ', ')" } + +# Production-shaped inputs (normally the script params and top-level state). +$BunPath = $ChildEnginePath +$CliPath = $HangChildPath +$BunRuntimeSource = "process" +$CodexHome = $CodexHome +$OpenCodexHome = $OpenCodexHome +$HostPid = 0 +$heartbeatPath = Join-Path $OpenCodexHome "tray-heartbeat.json" +$actionLogPath = Join-Path $OpenCodexHome "tray-actions.log" + +# WinForms stand-ins: Update-TrayState only sets plain properties on these. +$statusItem = [PSCustomObject]@{ Text = ""; Enabled = $false } +$safetyItem = [PSCustomObject]@{ Text = "" } +$notify = [PSCustomObject]@{ Text = ""; Icon = $null } +$startItem = [PSCustomObject]@{ Enabled = $true } +$stopItem = [PSCustomObject]@{ Enabled = $false } +$restartItem = [PSCustomObject]@{ Enabled = $false } +$onlineIcon = $null +$warningIcon = $null +$offlineIcon = $null + +# Mirror the tray's script-state initialization. +$script:online = $false +$script:port = 10100 +$script:proxyPid = $null +$script:wasOnline = $false +$script:startupHealth = $null +$script:startupHealthCheckedAt = 0 +$script:startupRefreshMs = 20000 +$script:startupProbeProcess = $null +$script:startupProbeOutputTask = $null +$script:startupProbeErrorTask = $null +$script:startupProbeStarted = 0 +$script:startupProbeTimeoutMs = 30000 +$script:pendingAction = $null +$script:pendingStarted = 0 +$script:pendingDeadline = 0 +$script:pendingOldProxyPid = $null +$script:pendingProcess = $null + +$childPid = 0 +try { + if ($Scenario -eq "Online") { + # First tick settles wasOnline and launches the probe through the real + # cameOnline gate against the fake /healthz the caller serves. + Update-TrayState + if (-not $script:online) { throw "online scenario never came online" } + if ($null -eq $script:startupProbeProcess) { throw "cameOnline gate did not launch a probe" } + } else { + # Stage the exact state the maintenance branch must handle: a probe in flight + # while the proxy is down. + Start-StartupHealthProbe + if ($null -eq $script:startupProbeProcess) { throw "probe did not start" } + } + $childPid = $script:startupProbeProcess.Id + Start-Sleep -Milliseconds 500 + # The timeout branch is only proven while the child is still alive here: an + # already-exited child would take the exited-probe path and every assertion + # below would pass without the Kill() ever running. + if ($script:startupProbeProcess.HasExited) { throw "probe child exited before maintenance; the timeout path was not exercised" } + $script:startupProbeStarted = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $script:startupProbeTimeoutMs - 5000 + + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Update-TrayState + $maintenanceMs = $watch.ElapsedMilliseconds + $onlineObserved = $script:online + $probeAfterMaintenance = $script:startupProbeProcess + Update-TrayState + $watch.Stop() + + # Liveness is evaluated BEFORE the safety cleanup in finally, so the verdict + # reports what the maintenance branch did, not what the driver cleaned up. + $childGone = $false + try { + $live = Get-Process -Id $childPid -ErrorAction Stop + $childGone = $live.HasExited + } catch { + $childGone = $true + } + + $pidFile = $env:OCX_PROBE_TEST_PID_FILE + $launches = 0 + if ($pidFile -and (Test-Path -LiteralPath $pidFile)) { + $launches = @((Get-Content -LiteralPath $pidFile | Where-Object { $_.Trim() -ne "" })).Count + } + + $verdict = [PSCustomObject]@{ + scenario = $Scenario + onlineObserved = [bool]$onlineObserved + maintenanceMs = $maintenanceMs + totalMs = $watch.ElapsedMilliseconds + childPid = $childPid + childTerminated = [bool]$childGone + probeCleared = ($null -eq $probeAfterMaintenance) -and ($null -eq $script:startupProbeProcess) + launches = $launches + } + $verdictJson = $verdict | ConvertTo-Json -Compress + # Set-Content -Encoding UTF8 emits a BOM on Windows PowerShell 5.1; write raw + # BOM-less UTF-8 the way the tray writes its heartbeat file. + [System.IO.File]::WriteAllText($ResultPath, $verdictJson, (New-Object System.Text.UTF8Encoding($false))) +} finally { + # Process.Start returns before the fake CLI writes its pid file, so a throw + # above (before that write) would leave the outer cleanup with no pid and a + # 120s sleeper behind. The in-hand pid closes that race. + if ($childPid -gt 0) { + try { + $leftover = Get-Process -Id $childPid -ErrorAction Stop + if (-not $leftover.HasExited) { Stop-Process -Id $childPid -Force -ErrorAction Stop } + } catch { + # Already exited or reaped; the verdict above already recorded the outcome. + $null = $_ + } + } +} diff --git a/tests/windows/windows-tray.test.ts b/tests/windows/windows-tray.test.ts index a0c8ce7e58b..5b15a2ae4d4 100644 --- a/tests/windows/windows-tray.test.ts +++ b/tests/windows/windows-tray.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { copyFileSync, existsSync, + mkdirSync, mkdtempSync, readFileSync, renameSync, @@ -21,6 +22,7 @@ import { readWindowsTrayRunValueWithAsyncRunner, readWindowsTrayRunValueWithRunner, replaceWindowsTrayOwnedFile, + windowsPowerShellPath, windowsTrayProcessArgs, windowsTrayRunValue, windowsTrayStatePathsOwned, @@ -409,18 +411,155 @@ describe("Windows tray packaging and command safety", () => { // finally block kills the active probe, waits briefly, then completes it. expect(source).toContain("terminating active startup-health probe on tray shutdown"); expect(source).toContain("startupProbeProcess.WaitForExit(3000)"); - // Probe lifecycle maintenance runs even while the proxy is offline, so a hung - // diagnostic is cleaned up outside the online-only UI branch; new probes still - // start only while online. - expect(source).toContain("$script:online -and ($cameOnline -or $refreshDue)"); + // Placement proof that runs on every platform: the behavioral test below is + // win32-only and the Windows CI leg runs on dispatch rather than on PRs, + // so merge-time coverage needs a lightweight check here too. The timeout + // maintenance must sit BEFORE the online-only UI branch: moving it inside + // flips the order and deleting it removes the anchor, and a plain + // substring could not tell inside from outside. The branch anchor is a + // line-anchored regex (not a substring) because the `$script:proxyPid` + // assignment a few lines above contains the same `if ($script:online) {` + // text inline. + const timeoutAnchorIdx = source.search( + /^\s*} elseif \(\$probeTimedOut\) \{$/m, + ); + const onlineBranchIdx = source.search(/^\s*if \(\$script:online\) \{$/m); + expect(timeoutAnchorIdx).toBeGreaterThanOrEqual(0); + expect(onlineBranchIdx).toBeGreaterThanOrEqual(0); + expect(timeoutAnchorIdx).toBeLessThan(onlineBranchIdx); + // Hung-child termination itself is proven behaviorally by "terminates a hung + // startup-health probe without stacking a replacement" below. // The malformed-payload guard requires a real boolean, matching the shared // server-side parser instead of accepting any non-null rebootSafe value. expect(source).toContain("($parsed.rebootSafe -is [bool])"); // If the async pipe setup fails after the child started, the child must be - // terminated, not merely disposed and lost. - expect(source).toContain("would leave the Bun child running"); + // terminated, not merely disposed and lost. That catch block logs a + // distinct string, which breaks if the branch is deleted. + expect(source).toContain("startup-health probe launch cleanup failed"); }); + // Behavioral proof for the probe lifecycle: the driver loads the REAL probe + // functions out of windows-tray.ps1 (via the PowerShell AST, so comment and + // whitespace edits cannot fake it), stages a REAL hung child through the + // real Start-StartupHealthProbe, backdates its start past the real 30s + // timeout (elapsed time is an input to the maintenance branch, not the logic + // under test), then invokes the real Update-TrayState ticks and reports + // observable process facts. Offline proves the maintenance branch runs + // outside the online-only UI gate; Online proves the refresh throttle stacks + // no replacement. Either scenario fails if the Kill() is deleted (the child + // survives) or if maintenance moves inside the online branch (the offline + // child survives). + test("terminates a hung startup-health probe without stacking a replacement", async () => { + if (process.platform !== "win32") return; + const psExe = windowsPowerShellPath(); + const driver = helperPath("windows-tray-probe-lifecycle-driver.ps1"); + const trayScript = repoPath("src", "tray", "windows-tray.ps1"); + for (const scenario of ["Offline", "Online"] as const) { + const directory = mkdtempSync(join(tmpdir(), "ocx-tray-probe-")); + const codexHome = join(directory, "codex"); + const openCodexHome = join(directory, "ohome"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(openCodexHome, { recursive: true }); + // The fake CLI name stays simple ASCII on purpose: the child engine here + // is powershell.exe (not bun), and its -Command parsing mangles paths + // with spaces or `&` even when quoted. + const hangChild = join(directory, "hangchild.ps1"); + writeFileSync(hangChild, [ + "$pidFile = $env:OCX_PROBE_TEST_PID_FILE", + "if ($pidFile) { Add-Content -LiteralPath $pidFile -Value $PID }", + "Start-Sleep -Seconds 120", + ].join("\r\n")); + const pidFile = join(directory, "pids.txt"); + const resultPath = join(directory, "verdict.json"); + let server: ReturnType | undefined; + try { + if (scenario === "Online") { + server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: request => { + if (new URL(request.url).pathname === "/healthz") { + return Response.json({ status: "ok", service: "opencodex", port: server!.port, pid: 424242 }); + } + return new Response("not found", { status: 404 }); + }, + }); + writeFileSync( + join(openCodexHome, "runtime-port.json"), + JSON.stringify({ port: server.port, hostname: "127.0.0.1" }), + ); + } else { + // Port 1 refuses immediately, so the offline premise holds even on a + // dev machine already running the proxy on 10100. + writeFileSync( + join(openCodexHome, "runtime-port.json"), + JSON.stringify({ port: 1, hostname: "127.0.0.1" }), + ); + } + const child = Bun.spawn([psExe, + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", driver, + "-TrayScriptPath", trayScript, + "-ChildEnginePath", psExe, + "-HangChildPath", hangChild, + "-CodexHome", codexHome, + "-OpenCodexHome", openCodexHome, + "-ResultPath", resultPath, + "-Scenario", scenario, + ], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, OCX_PROBE_TEST_PID_FILE: pidFile }, + }); + const stdoutPromise = new Response(child.stdout).text(); + const stderrPromise = new Response(child.stderr).text(); + const finished = await Promise.race([ + Promise.all([stdoutPromise, stderrPromise, child.exited]) + .then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode })), + Bun.sleep(30_000).then(() => null), + ]); + if (!finished) { + try { child.kill(); } catch { /* already exited */ } + await Bun.sleep(500); + expect(false, `${scenario}: probe driver hung for 30s (a blocked tick would wait out the 120s sleeper)`).toBe(true); + return; + } + const { stdout, stderr, exitCode } = finished; + expect(exitCode, `${scenario}: driver exit=${exitCode} stdout=${stdout.slice(0, 500)} stderr=${stderr.slice(0, 500)}`).toBe(0); + expect(existsSync(resultPath), `${scenario}: driver exited 0 without writing ${resultPath}`).toBe(true); + const verdict = JSON.parse(readFileSync(resultPath, "utf8")) as { + scenario: string; + onlineObserved: boolean; + maintenanceMs: number; + totalMs: number; + childPid: number; + childTerminated: boolean; + probeCleared: boolean; + launches: number; + }; + expect(verdict.scenario).toBe(scenario); + expect(verdict.onlineObserved, `${scenario}: online=${verdict.onlineObserved}; the premise of this scenario did not hold`).toBe(scenario === "Online"); + expect(verdict.childTerminated, `${scenario}: hung probe child ${verdict.childPid} survived the timeout`).toBe(true); + expect(verdict.probeCleared, `${scenario}: probe reference was not released after the kill`).toBe(true); + expect(verdict.launches, `${scenario}: expected exactly 1 probe launch, saw ${verdict.launches}`).toBe(1); + expect(verdict.totalMs, `${scenario}: two ticks took ${verdict.totalMs}ms; a UI-thread block would hang until the 120s sleeper exits`).toBeLessThan(20_000); + } finally { + try { + if (existsSync(pidFile)) { + for (const line of readFileSync(pidFile, "utf8").split(/\r?\n/)) { + const pid = Number(line.trim()); + if (Number.isSafeInteger(pid) && pid > 0) { + try { process.kill(pid); } catch { /* already reaped */ } + } + } + } + } catch { /* cleanup best-effort */ } + if (server) await server.stop(true); + removeTreeWithRetry(directory); + } + } + }, { timeout: SPAWN_BUDGET_MS }); + // This test really does launch PowerShell, which really does launch a Bun child, and // then rebinds the port to prove the child did not inherit the listen socket. Those // processes ARE the assertion — there is no version of this proof that fakes them. From 9a426d213c74a118de87f8de71391f41a576785c Mon Sep 17 00:00:00 2001 From: Dongkyu Kim Date: Sun, 20 Sep 2026 20:15:11 +0900 Subject: [PATCH 21/87] fix(gui): keep Apple SD Gothic Neo behind San Francisco (#5154) * fix(gui): keep Apple SD Gothic Neo behind San Francisco * test(gui): guard system font fallback precedence * fix(gui): preserve product font priority before system fallbacks * test(gui): cover all named system UI font fallbacks --------- Co-authored-by: stleamist <2215080+stleamist@users.noreply.github.com> --- docs/design-system/foundations.md | 2 +- gui/src/styles.css | 4 ++-- gui/tests/ui-font-fallback.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 gui/tests/ui-font-fallback.test.ts diff --git a/docs/design-system/foundations.md b/docs/design-system/foundations.md index a3b38f9eae5..c3f3dbb39f7 100644 --- a/docs/design-system/foundations.md +++ b/docs/design-system/foundations.md @@ -24,7 +24,7 @@ ### Font families -- `--font-ui`: 일반 UI, 제목, 본문, 버튼, 입력. Pretendard/Noto Sans KR/Apple SD Gothic Neo/Malgun Gothic을 포함해 한글 fallback을 보장한다. +- `--font-ui`: 일반 UI, 제목, 본문, 버튼, 입력. 제품 서체를 우선하고, 그 뒤에는 시스템 UI 폰트를 영문도 지원하는 한글 fallback보다 먼저 선언한다. 그래야 한글 fallback이 시스템 UI 폰트의 영문·숫자 글리프까지 대신 표시하지 않는다. 한글은 앞선 서체의 지원 여부에 따라 시스템의 언어별 fallback 또는 뒤에 선언된 한글 글꼴로 표시한다. - `--font-code`: 모델 ID, URL, 버전, 토큰 수, 로그, 코드. 숫자는 tabular 형태로 정렬한다. 외부 CDN 폰트를 사용하지 않는다. 프록시 관리 화면은 오프라인에서도 열려야 하고, 폰트 diff --git a/gui/src/styles.css b/gui/src/styles.css index 28c9277c1ed..9bdd757f6ae 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -87,8 +87,8 @@ --radius-round: 50%; --radius-pill: 999px; - /* typography: product UI first, Korean-safe fallbacks, mono only for machine data */ - --font-ui: "OpenAI Sans", "Pretendard Variable", Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; + /* Keep product fonts first, then system UI fonts before Korean fallbacks that also cover Latin. */ + --font-ui: "OpenAI Sans", "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", sans-serif; --font-code: ui-monospace, "SFMono-Regular", "Cascadia Code", "JetBrains Mono", "Noto Sans Mono CJK KR", Menlo, Consolas, monospace; --font: var(--font-ui); --mono: var(--font-code); diff --git a/gui/tests/ui-font-fallback.test.ts b/gui/tests/ui-font-fallback.test.ts new file mode 100644 index 00000000000..a5ec926f219 --- /dev/null +++ b/gui/tests/ui-font-fallback.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; + +const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); +const fontStack = css.replace(/\/\*[\s\S]*?\*\//g, "") + .match(/--font-ui\s*:\s*([^;]+);/)?.[1]; +if (!fontStack) throw new Error("The UI font stack is missing."); +const families = fontStack.split(",").map(family => + family.trim().replace(/^(["'])(.*)\1$/, "$2"), +); + +test("product fonts retain priority in the UI font stack", () => { + expect(families.slice(0, 3)).toEqual(["OpenAI Sans", "Pretendard Variable", "Pretendard"]); +}); + +// Keep system fonts ahead of Apple SD Gothic Neo, which also covers Latin. +// This guards fallback order; actual glyph selection requires a browser check. +test.each(["-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "system-ui"])( + "%s precedes Apple SD Gothic Neo in the UI font stack", + systemFont => { + const systemIndex = families.indexOf(systemFont); + const koreanIndex = families.indexOf("Apple SD Gothic Neo"); + expect(systemIndex).toBeGreaterThanOrEqual(0); + expect(koreanIndex).toBeGreaterThanOrEqual(0); + expect(systemIndex).toBeLessThan(koreanIndex); + }, +); From 5b7f777522a0860566b77233db625f0fdefd742f Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Sun, 20 Sep 2026 04:46:34 -0700 Subject: [PATCH 22/87] feat(provider): add StepFun provider support (#5313) --- .../docs/fr/getting-started/quickstart.md | 2 +- .../src/content/docs/fr/guides/providers.md | 2 +- .../docs/getting-started/quickstart.md | 2 +- .../src/content/docs/guides/providers.md | 4 +- .../docs/ja/getting-started/quickstart.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../docs/ko/getting-started/quickstart.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../docs/ru/getting-started/quickstart.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../docs/tr/getting-started/quickstart.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../docs/zh-cn/getting-started/quickstart.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../docs/zh-tw/getting-started/quickstart.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- gui/public/provider-icons/stepfun-color.svg | 1 + gui/src/provider-icons.ts | 2 + src/providers/registry/entries-extended.ts | 23 ++- src/providers/registry/model-seeds.ts | 22 +++ structure/ops/docs-and-release.md | 2 +- .../provider-registry-parity.test.ts | 2 +- tests/providers/stepfun-provider.test.ts | 166 ++++++++++++++++++ 23 files changed, 232 insertions(+), 20 deletions(-) create mode 100644 gui/public/provider-icons/stepfun-color.svg create mode 100644 tests/providers/stepfun-provider.test.ts diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md index 7a7c3f8cec3..73512a4a6c8 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` vous accompagne dans les étapes suivantes : -1. **Choix d’un fournisseur** — sélectionnez l’un des 95 préréglages intégrés au registre, ou `custom` pour saisir une +1. **Choix d’un fournisseur** — sélectionnez l’un des 96 préréglages intégrés au registre, ou `custom` pour saisir une URL de base et un adaptateur. 2. **Clé API** — collez une clé ou référencez une variable d’environnement telle que `${ANTHROPIC_API_KEY}`. 3. **Modèle par défaut** — pour les fournisseurs clés, locaux et personnalisés, acceptez le préréglage ou saisissez un identifiant de modèle. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index f34081cfcc5..7e9115e0033 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -283,7 +283,7 @@ existante n'est pas concernée. ## 3. Catalogue des clés API -opencodex fournit 95 préréglages intégrés : 79 à clé, 12 OAuth, trois locaux et un préréglage par défaut de +opencodex fournit 96 préréglages intégrés : 80 à clé, 12 OAuth, trois locaux et un préréglage par défaut de transfert ChatGPT. Dans le tableau de bord, le sélecteur **Ajouter un fournisseur** ouvre le tableau de bord du fournisseur à clé, valide la clé et l'enregistre ; la validation dépend du fournisseur. Parmi les entrées notables : diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 7734c9f2761..a673daf5e82 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 95 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 96 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index cd5b5c8b61c..b013cecfd2e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -412,7 +412,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 95 built-in presets: 79 key-based, 12 OAuth, three local, and one default +opencodex ships 96 built-in presets: 80 key-based, 12 OAuth, three local, and one default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it; validation is provider-specific. Notable entries: @@ -692,7 +692,7 @@ voice models on the same host. Two things worth knowing before you pick it. **A Muse Code subscription does not apply here:** Meta scopes that credential to the Muse Code CLI and bills any other key pay-as-you-go. And the Contributor tier is cheap because Meta trains on your prompts — -roughly 92% off input, 95% off output, and 99% off cached input — so keep confidential +roughly 92% off input, 96% off output, and 99% off cached input — so keep confidential material off it. Muse Spark is also reachable through resellers, with a narrower roster: `command-code` carries both tiers, while `opencode-go` serves only `muse-spark-1.3-contributor`. diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index 9c2d25a93e4..d9d83201963 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 95 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 96 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f60ba96b0e6..adad28ad00c 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -185,7 +185,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 95 個含まれています。キー方式 79、OAuth 12、ローカル 3、 +opencodex には組み込みプリセットが 96 個含まれています。キー方式 80、OAuth 12、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです: diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index 7e92511d56a..91d11f1eff3 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 95개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 96개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index cf423679604..0eea4be4b70 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -182,7 +182,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 95개 들어 있습니다. 키 방식 79개, OAuth 12개, 로컬 3개, +opencodex에는 빌트인 프리셋이 96개 들어 있습니다. 키 방식 80개, OAuth 12개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다: diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 088843f0f96..750d2fa7476 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 95 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 96 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index a79efd4d6d4..53e86522e2b 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -198,7 +198,7 @@ Inline JSON и лишние позиционные аргументы откло ## 3. Каталог API-ключей -opencodex поставляется с 95 встроенными пресетами: 79 на основе ключей, 12 OAuth, три локальных и +opencodex поставляется с 96 встроенными пресетами: 80 на основе ключей, 12 OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера. Наиболее заметные записи: diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md index 6a595544d76..d73cb2bff12 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -14,7 +14,7 @@ ocx init `ocx init` adım adım size rehberlik eder: -1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 95 önayardan birini +1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 96 önayardan birini veya bir temel URL ile adaptör yazmak için `custom` seçeneğini belirleyin. 2. **API anahtarı** — bir anahtar yapıştırın veya `${ANTHROPIC_API_KEY}` gibi bir ortam değişkenine başvurun. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index abca3a24279..c49040b0960 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -316,7 +316,7 @@ olmayan bir makineden oturum açmak bundan etkilenmez. ## 3. API anahtarı kataloğu -opencodex 95 yerleşik önayar ile birlikte gelir: 79 anahtar tabanlı, 12 +opencodex 96 yerleşik önayar ile birlikte gelir: 80 anahtar tabanlı, 12 OAuth, üç yerel ve bir varsayılan ChatGPT iletme önayarı. Kontrol panelinin **Sağlayıcı ekle** seçicisi bir anahtar sağlayıcısının kontrol panelini açar, anahtarı doğrular ve saklar; doğrulama sağlayıcıya özgüdür. Dikkate değer diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index 91ac4ba047b..14f76c58cb9 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 95 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 96 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index d2f4d1b5f5e..f4796581124 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -173,7 +173,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 95 个预设:79 个密钥预设、12 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 96 个预设:80 个密钥预设、12 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异。主要条目包括: diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md index 3af31658c5a..a0e17af51a2 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 會引導你完成: -1. **選擇 provider** —— 從內建 registry 的 95 個預設中選擇一個,或選擇 `custom` 手動輸入 +1. **選擇 provider** —— 從內建 registry 的 96 個預設中選擇一個,或選擇 `custom` 手動輸入 base URL 和 adapter。 2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。 3. **預設模型** —— 對於 API key、本機和 custom provider,可接受預設值或輸入模型 id。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 47594ee2578..44263dc002f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -239,7 +239,7 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s ## 3. API 金鑰目錄 -opencodex 內建 95 個 preset:79 個 key-based、12 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward +opencodex 內建 96 個 preset:80 個 key-based、12 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward preset。儀表板的 **Add provider** picker 會開啟 key provider 的 dashboard、驗證金鑰並儲存;驗證方式 依 provider 而異。主要條目如下。 diff --git a/gui/public/provider-icons/stepfun-color.svg b/gui/public/provider-icons/stepfun-color.svg new file mode 100644 index 00000000000..7d04a37bc80 --- /dev/null +++ b/gui/public/provider-icons/stepfun-color.svg @@ -0,0 +1 @@ +Stepfun diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index e7906e773cd..11bc1be0c2d 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -84,6 +84,7 @@ const PROVIDER_ICON_ALIASES: Record = { parallel: "parallel.svg", sambanova: "sambanova.svg", scaleway: "scaleway.svg", + stepfun: "stepfun-color.svg", siliconflow: "siliconflow.svg", synthetic: "synthetic.svg", together: "together.svg", @@ -168,6 +169,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { huggingface: "Hugging Face", "qwen-cloud": "Qwen Cloud", siliconflow: "SiliconFlow", + stepfun: "StepFun", "tencent-coding-plan": "Tencent Cloud Coding Plan", codebuddy: "CodeBuddy", "codebuddy-cn": "CodeBuddy CN", diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index a3ffe393c67..48ebd4242d7 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -102,6 +102,11 @@ import { OPPER_MODEL_CONTEXT_WINDOWS, OPPER_MODEL_MAX_OUTPUT_TOKENS, OPPER_MODEL_INPUT_MODALITIES, + STEPFUN_MODELS, + STEPFUN_MODEL_CONTEXT_WINDOWS, + STEPFUN_MODEL_INPUT_MODALITIES, + STEPFUN_NO_VISION_MODELS, + STEPFUN_REASONING_EFFORTS, } from "./model-seeds"; export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ @@ -1361,5 +1366,21 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ noVisionModels: CODEBUDDY_CN_NO_VISION_MODELS, note: "Official CodeBuddy Code CLI (Tencent Cloud), China/internal environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy (Global); credentials are never exchanged across regions. v1 disables CLI tools (--tools \"\"): text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", }, + { + id: "stepfun", + label: "StepFun", + baseUrl: "https://api.stepfun.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://platform.stepfun.com", + defaultModel: "step-5-preview", + models: STEPFUN_MODELS, + liveModels: true, + preserveCustomDestination: true, + modelContextWindows: STEPFUN_MODEL_CONTEXT_WINDOWS, + modelInputModalities: STEPFUN_MODEL_INPUT_MODALITIES, + noVisionModels: STEPFUN_NO_VISION_MODELS, + reasoningEfforts: STEPFUN_REASONING_EFFORTS, + note: "StepFun (阶跃星辰) official OpenAI-compatible API.", + }, ]; - diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index a1d9661395a..c3ee348b6ee 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -1026,3 +1026,25 @@ export const OPPER_TEXT_ONLY_MODELS = ["deepseek-v4-pro", "kimi-k3"]; export const OPPER_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( OPPER_MODELS.map(id => [id, OPPER_TEXT_ONLY_MODELS.includes(id) ? ["text"] : ["text", "image"]]), ); + +export const STEPFUN_MODELS = [ + "step-5-preview", + "step-3.5-flash", + "step-3.7-flash", +]; + +export const STEPFUN_MODEL_CONTEXT_WINDOWS: Record = { + "step-5-preview": 1_000_000, + "step-3.5-flash": 256_000, + "step-3.7-flash": 256_000, +}; + +export const STEPFUN_MODEL_INPUT_MODALITIES: Record = { + "step-5-preview": ["text", "image"], + "step-3.5-flash": ["text"], + "step-3.7-flash": ["text", "image"], +}; + +export const STEPFUN_NO_VISION_MODELS = ["step-3.5-flash"]; + +export const STEPFUN_REASONING_EFFORTS = ["low", "medium", "high"]; diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 8cdfd8b2183..1d1000b6678 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -46,7 +46,7 @@ Manual navigation is defined in `docs-site/astro.config.mjs`. When adding a publ sidebar and either add localized copies or intentionally accept Starlight fallback behavior. Provider preset totals are recounted from the current registry when a preset lands. The -documented split is 95 total: 79 key-based, 12 OAuth, three local, and one default +documented split is 96 total: 80 key-based, 12 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight quickstarts carry the same counts. diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 094eb8f1cb1..65f0cd0cc7f 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -46,7 +46,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [ "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opper", "opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", - "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", + "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", "stepfun", ]; describe("provider registry parity", () => { diff --git a/tests/providers/stepfun-provider.test.ts b/tests/providers/stepfun-provider.test.ts new file mode 100644 index 00000000000..ffb1143da33 --- /dev/null +++ b/tests/providers/stepfun-provider.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { KEY_LOGIN_PROVIDERS } from "../../src/oauth/key-providers"; +import { deriveProviderPresets, providerConfigSeed } from "../../src/providers/derive"; +import { enrichProviderFromCatalog } from "../../src/oauth/key-providers"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { routeModel } from "../../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { en } from "../../gui/src/i18n/en"; +import { interpolate, type TFn } from "../../gui/src/i18n/shared"; +import { formatProviderDisplayName, isCatalogProviderId, providerIconSrc } from "../../gui/src/provider-icons"; + +const englishT: TFn = (key, vars) => interpolate(en[key], vars); + +const BASE_URL = "https://api.stepfun.com/v1"; +const TEST_KEY = "test-stepfun-key-12345"; + +function getStepFunRegistryEntry() { + const entry = PROVIDER_REGISTRY.find(p => p.id === "stepfun"); + if (!entry) throw new Error("StepFun registry entry not found"); + return entry; +} + +function createStepFunConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "stepfun", + providers: { + stepfun: { + adapter: "openai-chat", + baseUrl: BASE_URL, + authMode: "key", + apiKey: TEST_KEY, + ...overrides, + }, + }, + }; +} + +describe("StepFun provider", () => { + test("appears in the provider registry and preset catalog", () => { + const entry = getStepFunRegistryEntry(); + expect(entry).toMatchObject({ + id: "stepfun", + label: "StepFun", + adapter: "openai-chat", + baseUrl: BASE_URL, + authKind: "key", + dashboardUrl: "https://platform.stepfun.com", + defaultModel: "step-5-preview", + liveModels: true, + preserveCustomDestination: true, + }); + expect(entry.models).toEqual([ + "step-5-preview", + "step-3.5-flash", + "step-3.7-flash", + ]); + + // Check presence in derived presets for UI catalog + const presets = deriveProviderPresets(); + const preset = presets.find(p => p.id === "stepfun"); + expect(preset).toBeDefined(); + expect(preset).toMatchObject({ + id: "stepfun", + label: "StepFun", + adapter: "openai-chat", + baseUrl: BASE_URL, + auth: "key", + defaultModel: "step-5-preview", + dashboardUrl: "https://platform.stepfun.com", + }); + + // Check presence in key login providers + expect(KEY_LOGIN_PROVIDERS.stepfun).toMatchObject({ + baseUrl: BASE_URL, + adapter: "openai-chat", + dashboardUrl: "https://platform.stepfun.com", + defaultModel: "step-5-preview", + liveModels: true, + }); + }); + + test("can save API key and enrich config from catalog seed", () => { + const entry = getStepFunRegistryEntry(); + const seed = providerConfigSeed(entry); + expect(seed).toMatchObject({ + adapter: "openai-chat", + baseUrl: BASE_URL, + authMode: "key", + defaultModel: "step-5-preview", + }); + + // Verify saving provider config with API Key and enriching metadata + const userConfig: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: BASE_URL, + authMode: "key", + apiKey: TEST_KEY, + }; + enrichProviderFromCatalog("stepfun", userConfig); + + expect(userConfig.apiKey).toBe(TEST_KEY); + expect(userConfig.defaultModel).toBe("step-5-preview"); + expect(userConfig.models).toEqual([ + "step-5-preview", + "step-3.5-flash", + "step-3.7-flash", + ]); + expect(userConfig.modelContextWindows?.["step-5-preview"]).toBe(1_000_000); + expect(userConfig.modelContextWindows?.["step-3.5-flash"]).toBe(256_000); + expect(userConfig.modelContextWindows?.["step-3.7-flash"]).toBe(256_000); + }); + + test("models can be selected and routed with correct metadata", () => { + const config = createStepFunConfig(); + + // Model 1: step-5-preview + const route5 = routeModel(config, "stepfun/step-5-preview"); + expect(route5.modelId).toBe("step-5-preview"); + expect(route5.provider.adapter).toBe("openai-chat"); + expect(route5.provider.baseUrl).toBe(BASE_URL); + expect(route5.provider.modelContextWindows?.["step-5-preview"]).toBe(1_000_000); + expect(route5.provider.modelInputModalities?.["step-5-preview"]).toEqual(["text", "image"]); + + // Model 2: step-3.5-flash + const route35 = routeModel(config, "stepfun/step-3.5-flash"); + expect(route35.modelId).toBe("step-3.5-flash"); + expect(route35.provider.modelContextWindows?.["step-3.5-flash"]).toBe(256_000); + expect(route35.provider.noVisionModels).toContain("step-3.5-flash"); + + // Model 3: step-3.7-flash + const route37 = routeModel(config, "stepfun/step-3.7-flash"); + expect(route37.modelId).toBe("step-3.7-flash"); + expect(route37.provider.modelContextWindows?.["step-3.7-flash"]).toBe(256_000); + expect(route37.provider.modelInputModalities?.["step-3.7-flash"]).toEqual(["text", "image"]); + }); + + test("builds requests targeting the correct StepFun endpoint", () => { + const config = createStepFunConfig(); + const route = routeModel(config, "stepfun/step-5-preview"); + const adapter = createOpenAIChatAdapter(route.provider); + + const request = adapter.buildRequest({ + modelId: route.modelId, + context: { + messages: [{ role: "user", content: "Hello StepFun", timestamp: Date.now() }], + }, + stream: false, + options: {}, + }); + + expect(request.url).toBe("https://api.stepfun.com/v1/chat/completions"); + expect(request.headers.Authorization).toBe(`Bearer ${TEST_KEY}`); + + const parsedBody = JSON.parse(String(request.body)); + expect(parsedBody.model).toBe("step-5-preview"); + expect(parsedBody.messages[0].content).toBe("Hello StepFun"); + }); + + test("resolves UI display name and brand icon", () => { + expect(formatProviderDisplayName("stepfun", englishT)).toBe("StepFun"); + expect(isCatalogProviderId("stepfun")).toBe(true); + expect(providerIconSrc("stepfun")).toBe("/provider-icons/stepfun-color.svg"); + }); +}); From eb9a103944fe4c08df0329b50a115cec04129c46 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 20:48:14 +0900 Subject: [PATCH 23/87] docs(devlog): open the round 2 follow-up unit (#5325) --- .../_plan/260920_round2_followups/000_plan.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 devlog/_plan/260920_round2_followups/000_plan.md diff --git a/devlog/_plan/260920_round2_followups/000_plan.md b/devlog/_plan/260920_round2_followups/000_plan.md new file mode 100644 index 00000000000..bc113d7d542 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/000_plan.md @@ -0,0 +1,69 @@ +# Round 2 — what the first batch left behind + +Status: OPEN. The sixteen post-2.60.0 bundles and the #5261 incident work all landed on `dev` +today. This unit collects what that round deliberately left open, plus two things it caused. + +Everything here is either a defect a user hit, a remainder a lane recorded rather than hid, or a +red check on `dev`. Nothing is new scope invented for its own sake. + +## Priority 1 — a 2.60.0 regression that locks users out of the transition + +#5321: on a Codex home whose history has been migrated to paginated form, enabling the integration +in its authless provider-table shape is hard-refused with +`history_paginated_openai_requires_native_writer`. Nothing is written and the integration stays +disabled. Before 2.60.0 the same transition completed, with the history relabel standing down while +the routing and catalog half was still written. + +The guard itself is right. `src/codex/history-provider.ts` explains why: a provider-table +transition removes the root `openai_base_url`, and a row already paginated cannot be relabeled, so +standing it down would route an openai-tagged thread to Codex's own OpenAI endpoint. Refusing to +relabel is correct; refusing the entire transition and saying "do not retry" without naming a way +forward is what traps the user. + +The reporter found the only exits by reading the preflight source: delete the affected +conversations, or downgrade. On their home that meant deleting 173 sessions. Neither is a +remediation this project can ship as the supported answer. + +#4812 is the same guard family from the other side: `restore`, `stop` and `uninstall` also refuse +on paginated history, which leaves the CLI pointed at a dead proxy port. The two belong in one lane +because a fix that unblocks activation while leaving recovery blocked trades one trap for another. + +## Priority 2 — `dev` is red from the desktop landing + +The app stack brought a `macos widget + bundle` job. MenuBarCore's 118 tests, the dashboard build, +the sidecar preparation and the WidgetKit appex build all pass; `tauri build` then fails with +`A public key has been found, but no private key`. The updater public key is committed while the +private key is not a CI secret, so Tauri refuses to produce a signed update artifact. + +This is configuration, not code. The CI job's purpose is to prove the appex and the app bundle +build and that the widget is embedded, which does not require a signed updater artifact. Release +signing belongs to the release workflow, where the key can be held as a secret. + +## Remainders the first round recorded rather than hid + +| Item | What is left | +| --- | --- | +| #5292 | `gui/src/pages/Logs.tsx` restates the recovery-kind union with nine of thirteen members, so four durable kinds have no label. The fix derives the GUI union from the roster instead of restating it. | +| #5261 | Generic OAuth and key login still discard the browser launch result, and the dashboard account roster keeps last-good rows after a failed refresh. | +| #4191 | The WebSocket failure projection is not threaded into the durable record, and the SSE fallback the issue asks for is a transport change. | +| #5180 | The shared cooldown and `Retry-After` handling are routing behaviour and were not in the stage-table branch. | +| #4942 + #4989 | Both express rows of the landed stage table and overlap in `upstream-retry.ts` and `passthrough-dispatch.ts`. They must not each buy an independent replacement send for one logical request, so they are one reworked change. | +| #2366, #3748, #3983, #5063 | Deferred as implemented because each adds a parallel store or a second emission path. The derived forms read from the landed recorder instead. | + +## Lanes + +| Lane | Scope | +| --- | --- | +| R1 | #5321 and #4812 — the paginated-history guard, from both activation and recovery | +| R2 | the `macos widget + bundle` failure on `dev` | +| R3 | #5292 and the two #5261 remainders | +| R4 | #4942 and #4989 as one rework, plus the #4191 and #5180 remainders | +| R5 | the four telemetry pull requests as derived consumers of the recorder | + +## Execution constraints + +Unchanged. One branch, ordered commits, one pull request to `dev` per lane. No native stack — the +desktop chain proved why: squashing the bottom of one detaches every child and the remaining work +has to be reconstructed. Carried contributor work needs a `Co-authored-by` trailer. No local +suites, typecheck, builds, installs or live `ocx` execution; verification is static review plus +exact-head hosted CI. Only the coordinator merges and closes. From d6d87440b7a0120cb0d8d1b005093ceceb5092d9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 21:19:25 +0900 Subject: [PATCH 24/87] fix(openai-chat): fold the developer role unless a destination records that it accepts it (#5334) #5213 removed a hostname test that decided the wire role, which was right: a gateway proxying OpenAI accepts `developer` and the hostname cannot say so. The replacement default was wrong in the other direction. Forwarding to every destination assumed each one accepts a role until an operator marks it, so a gateway that rejects it answered `400 role 'developer' is not allowed` and the turn never started. Nothing in this repository could see that. Every test asserted the new default and passed; what broke was outside the tree. The key is now tri-state and the unset state is the safe one: absent folds to `system`, `true` records an upstream that rejects the role, `false` records one that accepts it and the role is forwarded. Placement is untouched in all three cases, which is the contract #5213 established and this change preserves. The regression fixes the gap directly: an undeclared destination must fold, and the role must still never be read from the hostname. The ordering suites declare their destinations rather than asserting the default, because they are about where a reminder sits, not which role carries it. Co-authored-by: codex --- .../fr/reference/configuration/providers.md | 2 +- .../src/content/docs/guides/claude-code.md | 14 ++++--- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- src/adapters/openai-chat/messages.ts | 20 ++++++---- .../openai-chat-developer-position.test.ts | 38 ++++++++++++++----- .../openai/openai-chat-system-order.test.ts | 5 +++ ...l01-openai-chat-review-regressions.test.ts | 4 ++ 13 files changed, 66 insertions(+), 31 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index be5c9dcad1f..e96e79f5f20 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -132,7 +132,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | | `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | -| `foldDeveloperRoleToSystem?` | `boolean` | Envoyer un message `developer` en `system` pour un fournisseur `openai-chat` dont le service en amont refuse le rôle `developer`. Dans les deux cas le message conserve sa position dans la conversation ; seul le rôle change. La valeur par défaut est `false`, donc le rôle standard de Chat Completions est transmis tel quel. | +| `foldDeveloperRoleToSystem?` | `boolean` | Indique si une destination `openai-chat` accepte le rôle `developer`. Non défini signifie que rien n'a été enregistré pour cette destination, donc le message part en `system` ; `true` enregistre un service en amont qui refuse le rôle ; `false` en enregistre un qui l'accepte, et le rôle est transmis. Dans tous les cas le message conserve sa position dans la conversation ; seul le rôle change. Une destination qui refuse le rôle répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d'où l'état non enregistré replié par défaut. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5190ca1da94..83da2dad162 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -777,12 +777,14 @@ route. Pass `"haiku"` as the model placeholder. Set `claudeCode.stabilizePromptCache` to `true` in `config.json` to relocate supported trailing Claude harness notices from system instructions to a trailing user message on translated routes. The default is `false`. Enable it only when this role change is appropriate for your clients. It preserves fenced examples and unmatched text; native Anthropic passthrough is unchanged. The metadata-less prompt-cache key then follows stabilized instructions. This does not create conversation identity or guarantee upstream cache hits. On every translated Chat route, timeline reminders keep their position in the -conversation, after any pending tool results, and are forwarded with the -`developer` role. This prevents a newly appended reminder from rewriting the -leading system prompt, and stops a mid-conversation instruction from arriving -ahead of the turns it was written to follow. Set `foldDeveloperRoleToSystem` on -a provider whose upstream rejects the `developer` role; the reminder is then -sent as `system` in the same position. This applies with or without +conversation, after any pending tool results. This prevents a newly appended +reminder from rewriting the leading system prompt, and stops a mid-conversation +instruction from arriving ahead of the turns it was written to follow. The role +that slot carries is decided separately: a reminder is sent as `system` unless +the provider records `foldDeveloperRoleToSystem: false`, which states that the +upstream accepts the `developer` role and forwards it in the same position. An +upstream that does not accept it answers `400 role 'developer' is not allowed` +and the turn never starts, so an unrecorded destination folds. This applies with or without `stabilizePromptCache`, and native Anthropic passthrough is unchanged. Cache reuse still requires stable session identity and upstream cache availability. Changes to earlier instructions or tools, and conversation compaction, can still diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 20633fc2da4..6a2364e23c0 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -125,7 +125,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | -| `foldDeveloperRoleToSystem?` | `boolean` | 上流が `developer` ロールを受け付けない `openai-chat` プロバイダーで、`developer` メッセージを `system` として送ります。どちらの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。既定は `false` で、標準の Chat Completions ロールをそのまま転送します。 | +| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` の宛先が `developer` ロールを受け付けるかを記録します。未設定はこの宛先について何も記録されていないことを意味し、メッセージは `system` として送られます。`true` は上流がロールを拒否する記録、`false` は受け付ける記録で、ロールはそのまま転送されます。いずれの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。ロールを拒否する宛先は `400 role 'developer' is not allowed` を返してターンが始まらないため、未記録の既定は畳む側にしてあります。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 3731b1b3eb4..824a9826940 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -125,7 +125,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | -| `foldDeveloperRoleToSystem?` | `boolean` | 상위 서비스가 `developer` 역할을 거부하는 `openai-chat` 공급자에서 `developer` 메시지를 `system`으로 보냅니다. 어느 쪽이든 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 기본값은 `false`이며, 표준 Chat Completions 역할을 받은 그대로 전달합니다. | +| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` 목적지가 `developer` 역할을 받는지 기록합니다. 값이 없으면 이 목적지에 대해 기록된 것이 없다는 뜻이므로 메시지를 `system`으로 보냅니다. `true`는 상위 서비스가 역할을 거부한다는 기록이고, `false`는 받아들인다는 기록이라 역할을 그대로 전달합니다. 어느 경우에도 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 역할을 거부하는 목적지는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 상태의 기본값을 접는 쪽으로 둡니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c3eb866315b..6f780ed4ed6 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -203,7 +203,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | -| `foldDeveloperRoleToSystem?` | `boolean` | Send a `developer` message as `system` on an `openai-chat` provider whose upstream rejects the `developer` role. The message keeps its position in the conversation either way; only the role changes. Defaults to `false`, so the standard Chat Completions role is forwarded as sent. | +| `foldDeveloperRoleToSystem?` | `boolean` | Whether an `openai-chat` destination accepts the `developer` role. Unset means nothing has been recorded about this destination, so the message is sent as `system`; `true` records an upstream that rejects the role; `false` records one that accepts it, and the role is forwarded. The message keeps its position in the conversation in every case — only the role changes. A destination that rejects the role answers `400 role 'developer' is not allowed` and the turn never starts, which is why the unrecorded state is the folded one. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 1c73ea4571e..7107a9cb120 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -138,7 +138,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | -| `foldDeveloperRoleToSystem?` | `boolean` | Отправлять сообщение `developer` как `system` для провайдера `openai-chat`, чей вышестоящий сервис не принимает роль `developer`. В обоих случаях сообщение сохраняет свою позицию в разговоре; меняется только роль. По умолчанию `false`, то есть стандартная роль Chat Completions передаётся как есть. | +| `foldDeveloperRoleToSystem?` | `boolean` | Принимает ли назначение `openai-chat` роль `developer`. Значение не задано означает, что об этом назначении ничего не записано, поэтому сообщение отправляется как `system`; `true` фиксирует вышестоящий сервис, который роль отклоняет; `false` — тот, который её принимает, и роль передаётся как есть. В любом случае сообщение сохраняет свою позицию в разговоре, меняется только роль. Назначение, отклоняющее роль, отвечает `400 role 'developer' is not allowed`, и ход не начинается — поэтому незаписанное состояние по умолчанию свёрнуто. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 447f02b49d5..5a0c198d754 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -139,7 +139,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | -| `foldDeveloperRoleToSystem?` | `boolean` | Üst hizmeti `developer` rolünü kabul etmeyen bir `openai-chat` sağlayıcısında `developer` mesajını `system` olarak gönderir. Her iki durumda da mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Varsayılan `false` olduğundan standart Chat Completions rolü geldiği gibi iletilir. | +| `foldDeveloperRoleToSystem?` | `boolean` | Bir `openai-chat` hedefinin `developer` rolünü kabul edip etmediğini kaydeder. Ayarlanmamış olması bu hedef için hiçbir şey kaydedilmediği anlamına gelir; mesaj `system` olarak gönderilir. `true` rolü reddeden bir üst hizmeti, `false` ise kabul edeni kaydeder ve rol olduğu gibi iletilir. Her durumda mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Rolü reddeden bir hedef `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydedilmemiş durumun katlanmış olmasının nedeni budur. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 8fcfa10e6e8..90ab7966d17 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -125,7 +125,7 @@ selector,而不是分配一个新名称。 | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | -| `foldDeveloperRoleToSystem?` | `boolean` | 对上游拒绝 `developer` 角色的 `openai-chat` 提供方,将 `developer` 消息作为 `system` 发送。无论哪种方式,消息都会保留在对话中的原有位置,只有角色改变。默认为 `false`,即按原样转发标准的 Chat Completions 角色。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 记录某个 `openai-chat` 目的地是否接受 `developer` 角色。未设置表示尚未记录该目的地的情况,因此消息按 `system` 发送;`true` 记录上游拒绝该角色;`false` 记录其接受该角色,角色按原样转发。无论哪种情况,消息都保留在对话中的原有位置,只有角色改变。拒绝该角色的目的地会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,这就是未记录状态默认折叠的原因。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 15e4ce53a37..d90eea6e7c6 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -99,7 +99,7 @@ ocx models provider openrouter on | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | -| `foldDeveloperRoleToSystem?` | `boolean` | 對上游拒絕 `developer` 角色的 `openai-chat` 提供者,將 `developer` 訊息以 `system` 傳送。無論何者,訊息都會保留在對話中的原有位置,只有角色改變。預設為 `false`,亦即照原樣轉送標準的 Chat Completions 角色。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 記錄某個 `openai-chat` 目的地是否接受 `developer` 角色。未設定表示尚未記錄該目的地的情況,因此訊息以 `system` 傳送;`true` 記錄上游拒絕該角色;`false` 記錄其接受該角色,角色照原樣轉送。無論何者,訊息都保留在對話中的原有位置,只有角色改變。拒絕該角色的目的地會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,這就是未記錄狀態預設摺疊的原因。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index c30072e1ee6..a314ec01c82 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -114,13 +114,19 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv }; const nativeOpenAI = isNativeOpenAIChatTarget(provider); - // `developer` is part of the Chat Completions role set, so it is forwarded as itself. The - // host test above used to decide the role too, which assumed every OpenAI-compatible gateway - // rejects a standard role until proven otherwise — including gateways that proxy OpenAI — - // and quietly gave the instruction `system` precedence instead (#5213). A destination that - // really does reject it records that with `foldDeveloperRoleToSystem`, which converts the - // role where the message already is and never moves it. - const developerWireRole = provider.foldDeveloperRoleToSystem === true ? "system" : "developer"; + // `developer` is part of the Chat Completions role set, but not every OpenAI-compatible + // gateway accepts it: one that does not answers `400 role 'developer' is not allowed` and the + // turn never starts. #5213 removed a hostname test that decided the role, which was right — + // a gateway proxying OpenAI accepts the role and the hostname cannot say so. Defaulting to + // forwarding instead was wrong in the other direction: it assumed every destination accepts a + // role until an operator marks it, so a gateway that rejects it broke on the next request and + // no test in this repository could see it, because what breaks lives outside the repository. + // + // The key is tri-state and the unset state is the safe one. Absent means nobody has recorded + // what this destination accepts, so the role folds to `system`; `true` means it is known to + // reject the role; `false` means it is known to accept it and the role is forwarded. Either + // way the message keeps the slot it arrived in — only the role changes, never the position. + const developerWireRole = provider.foldDeveloperRoleToSystem === false ? "developer" : "system"; // A developer message keeps the slot it arrived in. Hoisting its text into the leading // system block moved a mid-conversation instruction ahead of every turn it was written to // follow, and the caller saw an ordinary answer either way (#5213). The Claude inbound mints diff --git a/tests/adapters/openai/openai-chat-developer-position.test.ts b/tests/adapters/openai/openai-chat-developer-position.test.ts index 311d109b804..b3db3f742b8 100644 --- a/tests/adapters/openai/openai-chat-developer-position.test.ts +++ b/tests/adapters/openai/openai-chat-developer-position.test.ts @@ -37,10 +37,9 @@ function wireMessages(provider: OcxProviderConfig): Array { test("a non-OpenAI gateway keeps the instruction between the two turns", () => { const messages = wireMessages(gateway); - expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); expect(messages[0]).toEqual({ role: "system", content: "base instructions" }); expect(messages[1]).toEqual({ role: "user", content: "First turn." }); - expect(messages[2]).toEqual({ role: "developer", content: "Answer in exactly one sentence." }); + expect(messages[2].content).toBe("Answer in exactly one sentence."); expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); }); @@ -54,25 +53,42 @@ describe("developer message placement on the Chat wire", () => { "http://localhost:1234/v1", "https://api.openai.com/v1", ]; + // Placement is asserted for both role states, because the role is decided separately and + // must never be able to move the message. for (const baseUrl of hosts) { - const messages = wireMessages({ ...gateway, baseUrl }); - expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); - expect(messages[2].content).toBe("Answer in exactly one sentence."); - expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); + for (const declared of [{}, { foldDeveloperRoleToSystem: false }, { foldDeveloperRoleToSystem: true }]) { + const messages = wireMessages({ ...gateway, baseUrl, ...declared }); + expect(messages).toHaveLength(4); + expect(messages[2].content).toBe("Answer in exactly one sentence."); + expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); + } } }); }); describe("developer role on the Chat wire", () => { - test("the role is forwarded as itself rather than inferred from the hostname", () => { + test("an undeclared destination folds the role rather than gambling on it", () => { + // The reason this is the default: a gateway that rejects the role answers + // `400 role 'developer' is not allowed` and the turn never starts. Forwarding by default + // put that failure outside the repository, where no test could reach it. for (const baseUrl of ["https://openrouter.ai/api/v1", "http://localhost:1234/v1", "https://api.openai.com/v1"]) { expect(wireMessages({ ...gateway, baseUrl })[2]).toEqual({ - role: "developer", + role: "system", content: "Answer in exactly one sentence.", }); } }); + test("the role still never depends on the destination hostname", () => { + const declared = { ...gateway, foldDeveloperRoleToSystem: false }; + for (const baseUrl of ["https://openrouter.ai/api/v1", "https://api.openai.com/v1"]) { + expect(wireMessages({ ...declared, baseUrl })[2].role).toBe("developer"); + } + for (const baseUrl of ["https://openrouter.ai/api/v1", "https://api.openai.com/v1"]) { + expect(wireMessages({ ...gateway, baseUrl })[2].role).toBe("system"); + } + }); + test("a destination that rejects the role converts it without moving the message", () => { const messages = wireMessages({ ...gateway, foldDeveloperRoleToSystem: true }); expect(messages.map(message => message.role)).toEqual(["system", "user", "system", "user"]); @@ -80,7 +96,9 @@ describe("developer role on the Chat wire", () => { expect(String(messages[0].content)).not.toContain("Answer in exactly one sentence."); }); - test("the opt-out is off unless the operator sets it", () => { - expect(wireMessages({ ...gateway, foldDeveloperRoleToSystem: false })[2].role).toBe("developer"); + test("a destination known to accept the role forwards it in the same slot", () => { + const messages = wireMessages({ ...gateway, foldDeveloperRoleToSystem: false }); + expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); + expect(messages[2]).toEqual({ role: "developer", content: "Answer in exactly one sentence." }); }); }); diff --git a/tests/adapters/openai/openai-chat-system-order.test.ts b/tests/adapters/openai/openai-chat-system-order.test.ts index 29511492412..ea2edf915f4 100644 --- a/tests/adapters/openai/openai-chat-system-order.test.ts +++ b/tests/adapters/openai/openai-chat-system-order.test.ts @@ -8,6 +8,10 @@ const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", apiKey: "local", + // These cases assert where a reminder sits, not which role carries it. The wire role folds + // to `system` unless a destination is recorded as accepting `developer`, so the destination + // is declared here to keep the ordering assertions reading the role they are about. + foldDeveloperRoleToSystem: false, }; function buildMessages(context: OcxParsedRequest["context"]): Array> { @@ -114,6 +118,7 @@ describe("chronological in-conversation system messages", () => { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", preserveReasoningContentModels: [model], + foldDeveloperRoleToSystem: false, }; const history = [ { role: "user", content: "Inspect the synthetic project." }, diff --git a/tests/routing/cl01-openai-chat-review-regressions.test.ts b/tests/routing/cl01-openai-chat-review-regressions.test.ts index ca0e68781b4..aece7696bc8 100644 --- a/tests/routing/cl01-openai-chat-review-regressions.test.ts +++ b/tests/routing/cl01-openai-chat-review-regressions.test.ts @@ -7,6 +7,10 @@ const baseProvider: OcxProviderConfig = { baseUrl: "https://api.openai.com/v1", apiKey: "sk-test", authMode: "key", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; this case is about placement after a tool result, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; function bodyFor(provider: OcxProviderConfig, parsed: OcxParsedRequest): Record { From d9cb0ebcb3aa4b46c3f6defe9358a65f2d394265 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 21:21:32 +0900 Subject: [PATCH 25/87] test(layout): register the StepFun provider test in both layout maps (#5335) Co-authored-by: Flowershangfromthebranches --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cc24f755ee1..dcc7dafffde 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -190,6 +190,7 @@ "chat-responses-control-integration.test.ts": "responses", "coding-agent-tool-result-images.test.ts": "adapters", "cold-spawn-warmup.test.ts": "ci-workflows", + "stepfun-provider.test.ts": "providers", "warmup-registration.test.ts": "ci-workflows", "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 454504049af..17dadddff96 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -22,6 +22,7 @@ "chat-responses-control-integration.test.ts": "responses", "coding-agent-tool-result-images.test.ts": "adapters", "cold-spawn-warmup.test.ts": "ci-workflows", + "stepfun-provider.test.ts": "providers", "warmup-registration.test.ts": "ci-workflows", "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", From 600075d23e90f58636a9c12ed4431ea5fad30b7a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 21:48:54 +0900 Subject: [PATCH 26/87] fix(lab): record that the Chat conformance destination accepts the developer role (#5341) #5334 folds the wire role unless a destination records acceptance. This vector is the one place that asserts the forwarded role, and it lives outside tests/, so the change missed it and dev went red with roles:value_mismatch. Co-authored-by: codex --- src/lab/conformance/fixture-provider.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lab/conformance/fixture-provider.ts b/src/lab/conformance/fixture-provider.ts index d389deb6733..9fe74178b3b 100644 --- a/src/lab/conformance/fixture-provider.ts +++ b/src/lab/conformance/fixture-provider.ts @@ -12,6 +12,11 @@ export function fixtureProviderConfig(adapter: string): OcxProviderConfig { models: ["fixture-model"], defaultModel: "fixture-model", liveModels: false, + // The wire role is no longer read from the hostname, and an undeclared destination folds + // `developer` to `system` because one that rejects the role answers 400 and the turn never + // starts. This fixture is the one place that asserts the forwarded role, so the destination + // it stands for records that it accepts it. Only the Chat adapter reaches that decision. + ...(adapter === "openai-chat" ? { foldDeveloperRoleToSystem: false } : {}), }; } From ab90bea6b1e53ba5c8f8c1eda538e88ec70682f3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 22:12:06 +0900 Subject: [PATCH 27/87] test(openai-chat): declare the dangling-toolcalls destination accepts the developer role (#5344) Co-authored-by: codex --- tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c21023..408dcacbd1e 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,11 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // These cases assert where a developer barrier lands relative to a pending tool call, not + // which role carries it. The wire role folds to `system` unless a destination records that + // it accepts `developer`, so the destination is declared here and the assertions keep + // reading the role they were written against. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { From 126f5b3ccdeac5e2dab4cbe52c9e06ff77c8072a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 22:27:27 +0900 Subject: [PATCH 28/87] test(responses): declare the inline-document destination accepts the developer role (#5346) Co-authored-by: codex --- tests/responses/chat-inline-document-bytes.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa05788..17aa779eb8d 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,11 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The case below asserts that a document survives on a `developer` turn, so it needs the + // role to reach the wire. That role folds to `system` unless a destination records that it + // accepts it, so the destination records it here; the document contract is what is under + // test, not the role decision. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic", From 1bccc4542a42dc353813166ce261b97e4d93121f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:17:31 +0900 Subject: [PATCH 29/87] ci(desktop): keep unsigned verification out of updater signing (#5338) * ci(desktop): keep the release updater key out of the verification build * test(lab): fold the conformance developer role with the destination default * docs(lab): describe the folded chat fixture contract * test(lab): pin the undeclared chat role fixture * test(lab): defer to the merged developer-role fixture fix * test(openai-chat): align dangling barriers with role folding * Revert "test(openai-chat): align dangling barriers with role folding" This reverts commit 4d427602b20a025ffea61f2ee02f466394c4ca34. --------- Co-authored-by: Ingwannu --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dbb2f7dede..098a0e75a24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1193,7 +1193,14 @@ jobs: - name: Build unsigned desktop app working-directory: desktop - run: bunx tauri build --ci --bundles app + # `createUpdaterArtifacts` is on and the updater public key is committed, so a plain + # `tauri build` stops with "A public key has been found, but no private key" unless + # TAURI_SIGNING_PRIVATE_KEY is set. This job proves the appex and the app bundle build + # and that the widget is embedded; it does not ship an update, and a verification + # build has no business holding the release key. Updater artifacts are therefore off + # here and the signing path stays in release.yml, which already reads the secret and + # refuses to publish a manifest when it is absent. + run: bunx tauri build --ci --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}' - name: Verify WidgetKit appex and desktop app run: | From 762df264d1c094db142b2b973b677b8a7ef7defc Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:51:58 +0900 Subject: [PATCH 30/87] fix(desktop,cli): make the build report its own state (#5327) * fix(desktop): keep proc-macro symbols in the release profile cargo applies profile.release strip to build scripts and proc macros. A stripped proc-macro dylib cannot be loaded by rustc, so the release build failed at ctor_proc_macro with a bare can't-find-crate that named the macro instead of the profile. The dev profile compiled the same graph. * docs(devlog): lock the desktop stabilization roadmap * docs(devlog): state per-phase acceptance evidence * fix(cli): report a dashboard bundle older than its sources The dashboard is a build artifact served from gui/dist, so a checkout that moves forward without bun run build:gui keeps serving the previous bundle. Nothing fails: the proxy answers, the page loads, and every feature added since the last build is absent, which reads as the feature being broken rather than unbuilt. A five-day-old bundle hid the whole menu-bar and widget section of the Usage page that way. ocx status now compares the newest source mtime under gui/src against the served bundle and names the rebuild. It reports and never rebuilds: a proxy that compiled a frontend while starting would trade silent staleness for a slow, surprising start. Unknown is not stale. A packaged install ships no gui/src beside the bundle, and a missing bundle is a separate condition, so neither raises the warning. * docs(devlog): plan the wp2 build-state guards * build(desktop): give the local build a path that needs no signing key tauri build always writes the updater archive, because createUpdaterArtifacts is true and plugins.updater.pubkey is set, and then refuses to finish without TAURI_SIGNING_PRIVATE_KEY. Both bundles already exist when that happens, so a local build reports a failure for a signing step it was never meant to perform and a wrapper cannot tell it apart from a real one. bun run build:local turns the artifact off for that invocation instead of leaving the key required and unmet, so nothing is skipped unsigned. Selecting bundle targets is not enough: createUpdaterArtifacts is a config flag, so --bundles app,dmg still produced the updater archive and still failed. The committed config is unchanged and the release path still refuses to publish an unsigned updater artifact. * fix(ci): stop the desktop lockfile shadowing the root one and name the freshness test for its domain Two failures on the exact head of this branch, both real. The widget job installs the desktop workspace with --frozen-lockfile on Bun 1.3.14. A bun.lock written inside desktop/ by a newer Bun shadows the root lockfile for any command run from that directory, so the job failed with "Unknown lockfile version" followed by "lockfile had changes, but lockfile is frozen" before it built anything. That file was committed by accident; the root lockfile is the only one this repository keeps, and .gitignore now says so. tests/server/gui-bundle-freshness.test.ts was registered as server in both inventories, but the gui domain seed claims ^(?:dashboard|gui|models|qwen|tencent)-, so resolveTarget answered gui and the membership oracle reported the file twice - once as a wrong target against the fixture and once as a seed disagreeing with the table. Renaming it to server-gui-bundle-freshness.test.ts puts the name in the domain that owns it rather than pinning an override, which is what that guard is there to prevent. * test(layout): register the StepFun provider test in both inventories tests/providers/stepfun-provider.test.ts landed on dev without an entry in either inventory, and no regex seed resolves its name, so the membership oracle has been failing on dev and on every branch cut from it since. Registering it under providers restores the gate for everyone rather than only for this stack. * Revert "test(layout): register the StepFun provider test in both inventories" This reverts commit e10b98f966. The same registration landed on dev as #5335 while this stack was in flight, and the rebase kept both because the two insertions chose different neighbours. Two entries for one key is not a second registration, it is a JSON object whose last value silently wins, so the duplicate goes rather than the one already on dev. --------- Co-authored-by: codex --- .gitignore | 5 + desktop/README.md | 15 +++ desktop/package.json | 1 + desktop/scripts/build-local.ts | 67 ++++++++++++ desktop/src-tauri/Cargo.toml | 10 ++ .../000_local_build.md | 52 +++++++++ .../010_roadmap.md | 69 ++++++++++++ .../011_acceptance.md | 49 +++++++++ .../020_build_state_guards.md | 59 ++++++++++ scripts/test-layout/layout.json | 1 + src/cli/index.ts | 13 +++ src/server/gui-freshness.ts | 103 ++++++++++++++++++ src/server/gui-static.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../server-gui-bundle-freshness.test.ts | 77 +++++++++++++ 15 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 desktop/scripts/build-local.ts create mode 100644 devlog/_plan/260920_desktop_app_stabilization/000_local_build.md create mode 100644 devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md create mode 100644 devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md create mode 100644 devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md create mode 100644 src/server/gui-freshness.ts create mode 100644 tests/server/server-gui-bundle-freshness.test.ts diff --git a/.gitignore b/.gitignore index 8f9c5f9fd59..3aa3ff0c143 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,8 @@ desktop/src-tauri/resources/ desktop/src-tauri/widget/ desktop/src-tauri/gen/ desktop/src-tauri/target/ +# Running `bun install` inside desktop/ writes a second lockfile that shadows the root one for +# any command run from that directory. CI installs the desktop workspace with --frozen-lockfile +# on an older Bun, so a shadowing lockfile written by a newer Bun fails the job with "Unknown +# lockfile version" before anything is built. The root lockfile is the only one this repo keeps. +desktop/bun.lock diff --git a/desktop/README.md b/desktop/README.md index 9106a50f67f..02e793a4766 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -26,6 +26,21 @@ bun run prepare-widget bunx tauri build ``` +## Building locally without signing keys + +`bunx tauri build` always produces the updater archive and then refuses to finish without +`TAURI_SIGNING_PRIVATE_KEY`, so a local build ends on `A public key has been found, but no private +key` **after** writing `OpenCodex.app` and the dmg. That exit code is right for a release and +misleading on a workstation. + +```sh +bun run build:local +``` + +This asks for the app and dmg only, so no updater archive is produced and none is expected to be +signed. It prints the bundle path and exits zero. The release path below is unchanged: a published +updater artifact still has to be signed. + ## Release packaging and updates The release workflow builds a macOS DMG, Windows MSI, Linux AppImage, and Debian package. diff --git a/desktop/package.json b/desktop/package.json index 09668ce09b7..7f168ff8e8b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -4,6 +4,7 @@ "scripts": { "dev": "tauri dev", "build": "tauri build", + "build:local": "bun scripts/build-local.ts", "prepare-sidecar": "bun scripts/prepare-sidecar.ts", "prepare-widget": "bash scripts/build-widget.sh" }, diff --git a/desktop/scripts/build-local.ts b/desktop/scripts/build-local.ts new file mode 100644 index 00000000000..633a27b6c15 --- /dev/null +++ b/desktop/scripts/build-local.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env bun +/** + * Unsigned local bundle build. + * + * `tauri build` always produces the updater archive, because `bundle.createUpdaterArtifacts` is + * true and `plugins.updater.pubkey` is set. Without `TAURI_SIGNING_PRIVATE_KEY` it then refuses to + * finish: + * + * Finished 2 bundles at: .../OpenCodex.app, .../OpenCodex_2.61.0_aarch64.dmg + * A public key has been found, but no private key. + * Error failed to build app + * + * Both bundles exist at that point. The non-zero exit is correct for a release — an unsigned + * updater artifact reaching users is worse than a failed build — but for someone building on their + * own machine it reports a failure for a signing step they were never meant to perform, and a + * wrapper script cannot tell it apart from a real failure. + * + * So this does not relax the check. It turns the updater artifact off for this one invocation, so + * there is nothing to sign and nothing is skipped unsigned. Selecting bundle targets is not enough: + * `createUpdaterArtifacts` is a config flag, so `--bundles app,dmg` still produces + * `OpenCodex.app.tar.gz (updater)` and still fails. The override has to reach the config itself. + */ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); + +/** Bundle targets that carry no updater archive. */ +const LOCAL_BUNDLES = ["app", "dmg"] as const; + +/** + * Config merged over `tauri.conf.json` for this invocation only. + * + * Turning the artifact off is what makes the signing key unnecessary, rather than leaving it + * required and unmet. The committed config keeps `createUpdaterArtifacts: true`, so the release + * build is untouched. + */ +const LOCAL_CONFIG = JSON.stringify({ bundle: { createUpdaterArtifacts: false } }); + +function run(): number { + const extra = process.argv.slice(2); + const args = [ + "tauri", "build", "--ci", + "--bundles", LOCAL_BUNDLES.join(","), + "--config", LOCAL_CONFIG, + ...extra, + ]; + const result = spawnSync("bunx", args, { cwd: desktopDir, stdio: "inherit" }); + if (result.error) { + console.error(`[build:local] could not start tauri: ${result.error.message}`); + return 1; + } + return result.status ?? 1; +} + +const status = run(); +if (status === 0) { + const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); + const app = join(bundleRoot, "macos", "OpenCodex.app"); + // Naming what exists is the point of the script: the previous output ended on an error line, so + // the artifacts it had already written were the least visible thing in it. + if (existsSync(app)) console.log(`[build:local] ${app}`); + console.log("[build:local] updater artifacts skipped; release signing is unchanged."); +} +process.exit(status); diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8683695613f..2548b000cd0 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -33,3 +33,13 @@ codegen-units = 1 lto = "thin" opt-level = "s" strip = "symbols" + +# Build scripts and proc macros are compiled for the host and loaded by rustc, so +# they must keep the symbols it resolves them through. `strip = "symbols"` above +# applies to them as well without this override, and a stripped proc-macro dylib +# fails to load with a bare `can't find crate`, naming the macro rather than the +# profile that removed it. `ctor-proc-macro`, pulled in by `tauri-utils`, is the +# one this repository hits: the release build stops at `pub use ctor_proc_macro::ctor` +# while the dev profile compiles the same graph. +[profile.release.build-override] +strip = false diff --git a/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md b/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md new file mode 100644 index 00000000000..db1792aa6f1 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md @@ -0,0 +1,52 @@ +# Desktop app stabilization — local build and conflict handling + +Status: OPEN. Opened against `dev` after the desktop stack landed as #5318 and the Claude Desktop +chain as #5319. This unit records what the first real local build found and what the app does when +it meets something already running. + +## The release profile could not build the app at all + +`cargo build --release` stopped at `ctor`, a transitive dependency of `tauri-utils`: + +``` +error[E0463]: can't find crate for `ctor_proc_macro` + --> ctor-0.8.0/src/lib.rs:244:9 +``` + +The same graph compiles in the dev profile. The difference is `[profile.release] strip = "symbols"`, +which cargo applies to build scripts and proc macros as well as to the crate being built. A proc +macro is a host dylib that rustc loads by symbol, so stripping it leaves a file rustc cannot read, +and the error names the macro rather than the profile that removed its symbols. + +`[profile.release.build-override] strip = false` restores it. The fix is one line plus the reason, +because the next person to read `can't find crate` will otherwise go looking at the dependency. + +This did not surface earlier because nothing had built the desktop app in release outside CI, and +CI's toolchain tolerated the stripped dylib. It is reproducible here on rustc 1.95.0. + +## Two installations at once + +The widget snapshot has two writers that target the same path: + +- `app/Sources/MenuBarCore/WidgetSnapshot.swift` builds it from + `~/Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json` +- `desktop/src-tauri/src/widget.rs:246` writes the same file from Rust + +Each deduplicates with its own in-process `lastWritten`, so two live writers do not settle: each +sees the other's file as changed, rewrites it, and calls `reloadTimelines`. The current build no +longer ships a standalone menu bar executable — `app/Package.swift` declares only the widget appex +and its test harness — so this is reachable only for a user who still has an earlier standalone +build installed. `tauri_plugin_single_instance` guards a second copy of the same bundle and cannot +see a different one. + +## Proxy ownership + +`spawned_by_us` in `desktop/src-tauri/src/lib.rs` records whether the app started the proxy, and +`tray.rs` enables **Stop proxy** from it. It is consumed with `swap(false)`, so the behaviour after +a stop-and-restart cycle needs checking rather than assuming. + +## Execution note + +This unit builds and installs locally at the maintainer's explicit request, which is a deliberate +exception to the no-local-build rule the surrounding batch worked under. Test suites are still not +run here. diff --git a/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md b/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md new file mode 100644 index 00000000000..af51c49d63e --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md @@ -0,0 +1,69 @@ +# Roadmap — six items, four work phases + +Status: LOCKED at wp1. Each later phase consumes one decade doc below and revalidates it at its +own P. The evidence in [000_local_build.md](000_local_build.md) is the ground truth; this file +turns it into an order of work. + +## What the local build and run actually showed + +Nothing about the usage feature was missing. `gui/dist` was five days old, so the bundle the +service served predated #5196 and could not contain the companion panel. `AGENTS.md` already says +the dashboard is served from `gui/dist` and lists `bun run build:gui`, so rebuilding after a +fast-forward was the existing procedure and skipping it was the mistake. After the rebuild the page +renders 74,974 requests, 18.66B tokens, 99% coverage, and a **menu bar and widget** section reading +"desktop app connected · just now". + +That reframes the work: five of the six items are real defects, and the sixth is the guard that +stops this particular mistake from being silent. + +## Order and why + +| Phase | Items | Why here | +| --- | --- | --- | +| wp2 | release profile, stale dist, updater-key exit | Nothing else can be built or verified until the release profile compiles; the dist guard belongs with it because both are "the build lied about its state". | +| wp3 | Claude Desktop first-party reachability | Independent of the build, and already verified by hand, so it lands on its own evidence. | +| wp4 | SVG app icons, widget gallery verdict | Both need a signed-or-explained bundle, so they come after the build is trustworthy. | + +## 020 — release profile, stale dist, updater key + +`[profile.release] strip = "symbols"` is applied by cargo to build scripts and proc macros as well +as to the crate being built. A proc macro is a host dylib rustc loads by symbol, so stripping it +produces `can't find crate for ctor_proc_macro` — an error that names the macro and never mentions +the profile. `[profile.release.build-override] strip = false` is the fix, already committed with +its reason. + +The stale-dist guard reports rather than repairs. The dashboard is a served artifact, so the honest +signal is "the bundle you are looking at is older than the source that produced it", surfaced where +someone will read it. Rebuilding automatically at startup would make a serving process do a build, +which is the wrong trade for a proxy. + +The updater-key exit is smaller: a local build that produced both bundles should not end on a +failure line about a signing key it was never given. + +## 030 — Claude Desktop first-party reachability + +`resolveClaudeDesktopMode` returns `gateway` when a gateway apply marker exists, and an explicit +`desktopMode` wins over everything. Both rules are right on their own: neither should flip a +working install silently. Together they mean the help text calls first-party "(default)" while an +existing user can never arrive there without discovering `--first-party` unaided. + +The fix is not to change the resolution. It is to make the choice visible at the moment an apply +happens, so a gateway apply says what it chose, that first-party exists, and how to switch. + +## 040 — SVG app icons and the widget verdict + +Icons today are a raster set with no vector source, so every size is an independent artifact that +can drift. One SVG source with a generation step makes the sizes derived rather than restated — +the same principle the test-layout registries follow. + +The widget question is answered with evidence, not hope. The bundle carries +`PlugIns/OpenCodexWidget.appex` and the app is ad-hoc signed +(`Identifier=opencodex_desktop-b89067d97e1c189c`, `flags=0x20002(adhoc,linker-signed)`), so the +phase records whether the widget appears in the gallery under that signing and, if it does not, +what specifically rejects it. + +## Constraints carried through every phase + +Stacked PRs, all pushes `--no-verify`, CI tracked after the fact rather than waited on. No local +test suite. Builds and real launches are the verification, because this unit exists precisely +because a build that was never run locally was assumed to work. diff --git a/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md b/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md new file mode 100644 index 00000000000..e25669f18ff --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md @@ -0,0 +1,49 @@ +# Acceptance evidence per phase + +The roadmap says what each phase does. This says what closes it, in terms of evidence that a +passing command or a rendered page does not by itself provide. + +## wp2 — release profile, stale dist, updater key + +**Release profile.** `cargo build --release` completes for the desktop crate. The regression is not +a test that runs cargo; it is an assertion that the release profile carries a build-override which +does not strip, because the failure mode is a profile setting and the symptom appears in an +unrelated crate. A test that only built something would pass on a machine whose rustc tolerates a +stripped proc-macro dylib, which is exactly how this reached `dev`. + +**Stale dist.** The check compares the newest source timestamp under `gui/src` against the built +bundle and reports when the bundle is older. It closes when a deliberately stale bundle produces +the report and a fresh one does not. Reporting is the contract: the proxy must not start a build. + +**Updater key.** A local bundle build that produced its artifacts ends by naming them, and the +missing updater key is stated as a skipped signing step rather than a failure. It closes when the +command's exit status reflects whether the bundles exist. + +## wp3 — Claude Desktop first-party reachability + +Closes when an apply that resolves to gateway says so, names first-party as the alternative, and +gives the exact command that switches. The resolution rules stay as they are: neither an explicit +`desktopMode` nor an existing apply marker may be overridden silently, because a working install +must not flip underneath its user. + +The evidence is the apply output on a machine that already carries a gateway marker — this one. +A unit test asserting the string is not sufficient on its own, because the defect was that the +help text and the resolved behaviour disagreed, and only running the real path shows which wins. + +## wp4 — SVG icons and the widget verdict + +**Icons.** One SVG source exists and every raster size is generated from it by a committed script. +It closes when regenerating produces byte-identical output for unchanged input, so the sizes are +derived rather than restated. + +**Widget.** The verdict is recorded either way. If the widget appears in the gallery under ad-hoc +signing, that is the finding. If it does not, the phase records what rejects it — the specific +system log line or the signing requirement — rather than reporting an absence. An unverified +"should work" closes nothing. + +## What none of these accept + +A green pull request is not evidence for any item here, because every one of them was invisible to +CI. The release profile failed only on a toolchain CI does not use, the stale bundle is a runtime +artifact CI rebuilds, the mode disagreement needs an existing install, and the widget needs a real +login session. Each phase therefore carries a local run alongside its hosted check. diff --git a/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md b/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md new file mode 100644 index 00000000000..fe67b3411a8 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md @@ -0,0 +1,59 @@ +# wp2 — the build telling the truth about its own state + +Two defects and one rough edge, all of the same shape: the build produced a state nobody could see +from its output. + +## 1. The release profile could not compile the app (landed) + +`cargo build --release` stopped at `ctor` with `can't find crate for ctor_proc_macro`. The dev +profile compiled the identical graph. `[profile.release] strip = "symbols"` is applied by cargo to +build scripts and proc macros as well as to the crate under build, and a proc macro is a host dylib +rustc loads by symbol, so stripping it leaves a file rustc cannot read. The error names the macro +and never mentions the profile that removed its symbols. + +`[profile.release.build-override] strip = false` in `desktop/src-tauri/Cargo.toml`, with the reason +in a comment because the next reader of that error will otherwise go looking at the dependency. +Verified by `cargo build --release -p ctor` failing before and passing after, and by the full +`tauri build` reaching both bundles afterwards. + +## 2. A stale dashboard bundle was invisible (landed) + +`gui/dist` was five days old, so the served page predated #5196 and could not contain the usage +companion panel. Nothing failed — the proxy answered and the page loaded, and the feature simply was +not in the bundle, which reads as the feature being broken. + +`src/server/gui-freshness.ts` compares the newest mtime under `gui/src` with the served bundle and +`ocx status` prints the rebuild command beside the dashboard URL. It reports and never rebuilds: a +proxy compiling a frontend at startup trades silent staleness for a slow, surprising start. + +Unknown is not stale, because a packaged install ships no `gui/src` and a missing bundle is a +separate condition. `node_modules` is skipped so a dependency install cannot make sources look +newer than they are. Four regressions in `tests/server/server-gui-bundle-freshness.test.ts` hold those +cases, and the live check was confirmed by touching a source file and watching the warning appear +and then disappear after a rebuild. + +## 3. A local build ends on a failure after succeeding (this phase) + +`createUpdaterArtifacts` is true and `plugins.updater.pubkey` is set in `tauri.conf.json`, so Tauri +always builds the updater archive and then refuses to finish without `TAURI_SIGNING_PRIVATE_KEY`: + +``` +Finished 2 bundles at: .../OpenCodex.app, .../OpenCodex_2.61.0_aarch64.dmg +A public key has been found, but no private key. Make sure to set TAURI_SIGNING_PRIVATE_KEY +Error failed to build app +``` + +Both bundles exist at that point. The command still exits non-zero, so a developer building locally +sees a failure for a signing step they were never meant to perform, and a script wrapping the build +cannot distinguish this from a real failure. + +The release path must keep failing here: an unsigned updater artifact shipped to users is worse +than a failed release. So the fix is not to relax the check but to give the local build a path that +does not ask for the artifact at all — an explicit script that builds the app and dmg without +updater artifacts, documented beside the existing release instructions. + +### Acceptance + +A local build command produces `OpenCodex.app` and the dmg and exits zero without a signing key. +The release instructions still describe the signed path, and nothing weakens the requirement that a +published updater artifact is signed. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dcc7dafffde..574ec26af9c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -578,6 +578,7 @@ "codex-shim-readiness.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", + "server-gui-bundle-freshness.test.ts": "server", "codex-spark-visibility.test.ts": "codex-integration", "codex-sqlite-home.test.ts": "codex-integration", "codex-sync-api.test.ts": "codex-integration", diff --git a/src/cli/index.ts b/src/cli/index.ts index 98843494e44..ff48ca71f02 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,9 @@ #!/usr/bin/env bun import { spawn } from "node:child_process"; import { homedir } from "node:os"; +import { join } from "node:path"; +import { findGuiDist } from "../server/gui-static"; +import { inspectGuiBundleFreshness, staleGuiBundleLines } from "../server/gui-freshness"; // Best-effort recovery for runtime execution and spawned children if launched // from an unlinked/deleted working directory (runs after hoisted ESM module imports). @@ -1596,6 +1599,16 @@ async function handleStatus() { } } console.log(` Dashboard: ${status.json.dashboard.url}${local}`); + // The dashboard is a build artifact, so a checkout that moved without `bun run build:gui` keeps + // serving the previous bundle and every feature added since simply does not appear (#5196's + // usage panel was invisible this way for five days). Reported next to the dashboard URL, which + // is where someone looks when the page is wrong. + for (const line of staleGuiBundleLines(inspectGuiBundleFreshness({ + bundlePath: findGuiDist(), + sourcePath: join(import.meta.dir, "..", "..", "gui", "src"), + }))) { + console.log(` ${line}`); + } console.log(` Config: ${status.json.paths.config}${local}`); console.log(` PID file: ${status.json.paths.pid}${local}`); console.log(` Runtime: ${status.json.paths.runtime}${local}`); diff --git a/src/server/gui-freshness.ts b/src/server/gui-freshness.ts new file mode 100644 index 00000000000..1d3c6b317d8 --- /dev/null +++ b/src/server/gui-freshness.ts @@ -0,0 +1,103 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Whether the served dashboard bundle predates the sources it was built from. + * + * The dashboard is a build artifact: `AGENTS.md` records that `gui/` is served from `gui/dist`, + * so a checkout that moves forward without `bun run build:gui` keeps serving the previous bundle. + * Nothing fails when that happens. The proxy answers, the page loads, and every feature added since + * the last build is simply absent — which reads as the feature being broken rather than unbuilt. + * A five-day-old bundle hid the entire menu-bar and widget section of the Usage page this way. + * + * This reports; it never rebuilds. A proxy that compiled a frontend while starting would trade a + * silent staleness for a slow, surprising start, and the rebuild belongs to whoever moved the + * checkout. + */ +export interface GuiBundleFreshness { + /** Absolute path of the served bundle, or null when no bundle was found. */ + bundlePath: string | null; + /** Newest mtime under the bundle, in epoch milliseconds. */ + bundleModifiedMs: number | null; + /** Newest mtime under the GUI sources, in epoch milliseconds. */ + sourceModifiedMs: number | null; + /** True only when both sides are known and the sources are strictly newer. */ + stale: boolean; +} + +/** Directory entries that never carry meaningful build input or output timestamps. */ +const IGNORED_DIRECTORIES = new Set(["node_modules", ".git", ".vite", ".cache"]); + +/** + * Newest mtime beneath `root`, or null when the tree is missing or empty. + * + * Walking is bounded by `maxEntries` because this runs on a status path: a pathological tree must + * cost a predictable amount rather than stalling the command that reports on it. Hitting the bound + * yields the newest value seen so far, which can only make the comparison more conservative. + */ +export function newestModifiedMs(root: string, maxEntries = 20_000): number | null { + if (!existsSync(root)) return null; + let newest: number | null = null; + let seen = 0; + const queue: string[] = [root]; + while (queue.length > 0) { + const current = queue.pop()!; + let entries; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (seen >= maxEntries) return newest; + if (entry.isSymbolicLink()) continue; + const full = join(current, entry.name); + if (entry.isDirectory()) { + if (IGNORED_DIRECTORIES.has(entry.name)) continue; + queue.push(full); + continue; + } + if (!entry.isFile()) continue; + seen += 1; + try { + const mtime = statSync(full).mtimeMs; + if (newest === null || mtime > newest) newest = mtime; + } catch { + // A file that vanished mid-walk cannot make the bundle look fresher than it is. + } + } + } + return newest; +} + +/** + * Compare the built bundle against its sources. + * + * Unknown is not stale. A packaged install has no `gui/src` beside it, and a missing bundle is a + * different condition with its own message elsewhere; neither should produce a rebuild warning. + */ +export function inspectGuiBundleFreshness(input: { + bundlePath: string | null; + sourcePath: string; +}): GuiBundleFreshness { + const bundleModifiedMs = input.bundlePath === null ? null : newestModifiedMs(input.bundlePath); + const sourceModifiedMs = newestModifiedMs(input.sourcePath); + const stale = bundleModifiedMs !== null + && sourceModifiedMs !== null + && sourceModifiedMs > bundleModifiedMs; + return { + bundlePath: input.bundlePath, + bundleModifiedMs, + sourceModifiedMs, + stale, + }; +} + +/** Operator-facing lines for a stale bundle; empty when the bundle is current or unknown. */ +export function staleGuiBundleLines(freshness: GuiBundleFreshness): string[] { + if (!freshness.stale) return []; + return [ + "Dashboard bundle is older than the GUI sources, so the page is missing everything built since.", + "Rebuild it with: bun run build:gui", + ]; +} diff --git a/src/server/gui-static.ts b/src/server/gui-static.ts index 9a6439f0971..3057dc748ee 100644 --- a/src/server/gui-static.ts +++ b/src/server/gui-static.ts @@ -20,7 +20,7 @@ const MIME_TYPES: Record = { */ const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/; -function findGuiDist(): string | null { +export function findGuiDist(): string | null { const candidates = [ process.env.OPENCODEX_GUI_DIST, ...(isStandaloneBinary() ? [join(standaloneRoot(), "gui", "dist")] : []), diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 17dadddff96..6a492dc56d1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -409,6 +409,7 @@ "codex-shim-readiness.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", + "server-gui-bundle-freshness.test.ts": "server", "codex-spark-visibility.test.ts": "codex-integration", "codex-sqlite-home.test.ts": "codex-integration", "codex-sync-api.test.ts": "codex-integration", diff --git a/tests/server/server-gui-bundle-freshness.test.ts b/tests/server/server-gui-bundle-freshness.test.ts new file mode 100644 index 00000000000..16860c1e08b --- /dev/null +++ b/tests/server/server-gui-bundle-freshness.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + inspectGuiBundleFreshness, + newestModifiedMs, + staleGuiBundleLines, +} from "../../src/server/gui-freshness"; + +/** Write `name` under `root` with an explicit mtime, creating parents as needed. */ +function writeAt(root: string, name: string, secondsSinceEpoch: number): string { + const path = join(root, name); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, "x"); + utimesSync(path, secondsSinceEpoch, secondsSinceEpoch); + return path; +} + +describe("gui bundle freshness", () => { + test("sources newer than the bundle are stale, and the advice names the rebuild", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-fresh-")); + writeAt(root, "dist/assets/index-AAAAAAAA.js", 1_000_000); + writeAt(root, "src/pages/Usage.tsx", 2_000_000); + + const freshness = inspectGuiBundleFreshness({ + bundlePath: join(root, "dist"), + sourcePath: join(root, "src"), + }); + + expect(freshness.stale).toBe(true); + const lines = staleGuiBundleLines(freshness); + expect(lines.length).toBeGreaterThan(0); + expect(lines.join("\n")).toContain("bun run build:gui"); + }); + + test("a bundle rebuilt after its sources is not stale and says nothing", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-fresh-")); + writeAt(root, "src/pages/Usage.tsx", 1_000_000); + writeAt(root, "dist/assets/index-BBBBBBBB.js", 2_000_000); + + const freshness = inspectGuiBundleFreshness({ + bundlePath: join(root, "dist"), + sourcePath: join(root, "src"), + }); + + expect(freshness.stale).toBe(false); + expect(staleGuiBundleLines(freshness)).toEqual([]); + }); + + test("an unknown side is never stale, because a packaged install ships no sources", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-fresh-")); + writeAt(root, "dist/assets/index-CCCCCCCC.js", 2_000_000); + + const missingSources = inspectGuiBundleFreshness({ + bundlePath: join(root, "dist"), + sourcePath: join(root, "src"), + }); + expect(missingSources.sourceModifiedMs).toBeNull(); + expect(missingSources.stale).toBe(false); + + const missingBundle = inspectGuiBundleFreshness({ + bundlePath: null, + sourcePath: join(root, "dist"), + }); + expect(missingBundle.bundleModifiedMs).toBeNull(); + expect(missingBundle.stale).toBe(false); + }); + + test("node_modules cannot make a tree look newer than its own files", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-fresh-")); + writeAt(root, "src/pages/Usage.tsx", 1_000_000); + writeAt(root, "src/node_modules/dep/index.js", 9_000_000); + + expect(newestModifiedMs(join(root, "src"))).toBe(1_000_000_000); + }); +}); From 5f961d649a2edc2e4647fa18c51901938e7434b3 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 00:04:57 +0900 Subject: [PATCH 31/87] fix(claude-desktop): say that first-party exists when gateway is applied (#5328) ocx claude desktop --help calls first-party "(default)", but an existing install never reaches it. resolveClaudeDesktopMode keeps a machine where it is: an explicit claudeCode.desktopMode wins, and a stored gateway apply marker keeps gateway. Both rules are right, because a working Desktop install must not flip underneath its user when a default moves. Together they mean a bare apply lands on gateway and says nothing about the alternative, so the only way to first-party is to discover --first-party unaided. The resolution is unchanged. The apply now names why gateway was chosen and the command that switches. It stays silent when the user asked for gateway explicitly, on a connected client where first-party cannot run, and on a machine with no stored preference, where gateway was a fallback rather than a choice and naming an unavailable mode would be advice that fails. Co-authored-by: codex --- scripts/test-layout/layout.json | 1 + src/cli/claude-desktop.ts | 45 ++++++++++++++++ .../claude-desktop-mode-explanation.test.ts | 53 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 100 insertions(+) create mode 100644 tests/claude-integration/claude-desktop-mode-explanation.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 574ec26af9c..dd578ff1571 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1607,6 +1607,7 @@ "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", + "claude-desktop-mode-explanation.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server" }, "migrated": [ diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 92efabad4d0..238e6bb03e8 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -18,6 +18,7 @@ import { import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; import { applyDesktopFirstParty, + isClaudeDesktopMode, recordClaudeDesktopMode, removeDesktopFirstParty, resolveClaudeDesktopApplyMode, @@ -196,6 +197,44 @@ export function parseDesktopApplyArgs( return { target: { kind, mode: parsedMode.mode } }; } +/** + * Why a gateway apply happened when the help text calls first-party the default. + * + * `resolveClaudeDesktopMode` keeps an existing install where it is: an explicit + * `claudeCode.desktopMode` wins, and a stored gateway apply marker keeps gateway. Both rules are + * right — a working Desktop install must not flip underneath its user because a default moved. + * Together they mean an existing gateway user never arrives at first-party without discovering + * `--first-party` unaided, while `ocx claude desktop --help` tells them first-party is "(default)". + * + * The fix is not to change the resolution. It is to say, at the moment of the apply, that the + * other mode exists and what selects it. Returns null when the user asked for gateway explicitly, + * because they already know, and when first-party is simply unavailable here — a connected client + * or a disabled intercept cannot run it, so offering it would be advice that fails. + */ +export function gatewayModeExplanation(input: { + requestedExplicitly: boolean; + config: Pick; + connection?: ClientConnectionState; +}): string[] { + if (input.requestedExplicitly) return []; + const connection = input.connection ?? readClientConnectionState(); + if (connection.kind === "connected") return []; + // Only a stored preference is worth explaining. Without one, gateway was chosen because + // first-party cannot run here, and naming an unavailable alternative is advice that fails. + const savedMode = input.config.claudeCode?.desktopMode; + const hasSavedGateway = isClaudeDesktopMode(savedMode) && savedMode === "gateway"; + const hasApplyMarker = input.config.claudeCode?.desktopProfile?.appliedFingerprint !== undefined; + if (!hasSavedGateway && !hasApplyMarker) return []; + const reason = hasSavedGateway + ? "this machine has claudeCode.desktopMode saved as gateway" + : "this machine carries a previous gateway apply"; + return [ + `Applied the gateway profile because ${reason}; an existing install is never switched for you.`, + "First-party keeps Desktop on your claude.ai account and routes only the Code tab through the local proxy:", + " ocx claude desktop apply --first-party", + ]; +} + /** * First-party apply: settings.json env only. The intercept pair the env points at runs inside * the hub process, so this is a local-hub operation — a connected client machine cannot reach @@ -361,6 +400,12 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf console.log("Desktop 앱 설정은 그대로이며, Code 탭의 Claude Code만 로컬 프록시를 거칩니다."); } else { console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`); + for (const line of gatewayModeExplanation({ + requestedExplicitly: applyFlags.some(flag => flag !== "--first-party"), + config: loadConfig(), + })) { + console.log(line); + } } // The write landed; only the bookkeeping marker did not. Saying nothing // would leave the saved-vs-applied display wrong with no explanation. diff --git a/tests/claude-integration/claude-desktop-mode-explanation.test.ts b/tests/claude-integration/claude-desktop-mode-explanation.test.ts new file mode 100644 index 00000000000..a2b6e80529e --- /dev/null +++ b/tests/claude-integration/claude-desktop-mode-explanation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { gatewayModeExplanation } from "../../src/cli/claude-desktop"; +import type { ClientConnectionState } from "../../src/client/state"; + +const disconnected = { kind: "disconnected" } as ClientConnectionState; +const connected = { kind: "connected" } as unknown as ClientConnectionState; + +describe("gateway apply explains the first-party alternative", () => { + test("a stored gateway marker is named as the reason, with the command that switches", () => { + const lines = gatewayModeExplanation({ + requestedExplicitly: false, + config: { claudeCode: { desktopProfile: { appliedFingerprint: "abc123" } } }, + connection: disconnected, + }); + + expect(lines.join("\n")).toContain("previous gateway apply"); + expect(lines.join("\n")).toContain("ocx claude desktop apply --first-party"); + }); + + test("an explicit saved desktopMode is named as itself, not as a leftover marker", () => { + const lines = gatewayModeExplanation({ + requestedExplicitly: false, + config: { claudeCode: { desktopMode: "gateway" } }, + connection: disconnected, + }); + + expect(lines.join("\n")).toContain("desktopMode saved as gateway"); + }); + + test("asking for gateway explicitly says nothing, because the user already chose", () => { + expect(gatewayModeExplanation({ + requestedExplicitly: true, + config: { claudeCode: { desktopProfile: { appliedFingerprint: "abc123" } } }, + connection: disconnected, + })).toEqual([]); + }); + + test("a connected client says nothing, because first-party cannot run there", () => { + expect(gatewayModeExplanation({ + requestedExplicitly: false, + config: { claudeCode: { desktopProfile: { appliedFingerprint: "abc123" } } }, + connection: connected, + })).toEqual([]); + }); + + test("a fresh machine that fell back to gateway is not told to switch to something unavailable", () => { + expect(gatewayModeExplanation({ + requestedExplicitly: false, + config: {}, + connection: disconnected, + })).toEqual([]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6a492dc56d1..e1e266abf4c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1439,5 +1439,6 @@ "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", + "claude-desktop-mode-explanation.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server" } From 7c0ffecc0f90cb28d555fcb5a3405de209611c99 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 00:16:57 +0900 Subject: [PATCH 32/87] feat(desktop): generate every app icon from one SVG source (#5329) * feat(desktop): generate every app icon from one SVG source The icon set was eighteen independent raster files with no vector source. Nothing tied Square107x107Logo.png to icon.png, nothing could detect a hand-edited size, and the .icns and .ico containers hid a wrong member entirely because it never appears in a diff. icon.svg is now the single source and desktop/scripts/generate-icons.ts derives the rest: fifteen PNGs through rsvg-convert, the .icns through iconutil from its ten members, and the .ico written directly with six PNG-embedded entries. 'bun run icons:check' regenerates into a temporary directory and compares byte for byte, so a hand-edited raster fails instead of quietly disagreeing with its source. The geometry is measured rather than redrawn. The mark in icon.png spans 58..453 on both axes with a 48-wide stroke turning at radius 135, so a centred stroke sits at x=82 y=82 w=348 h=348; sweeping the corner radius put rx=127 within 430 of 262144 pixels at 512 - antialiasing along the curve, not a changed silhouette. The mark stays black on transparency because macOS and Windows each composite it over their own background. * fix(desktop): compare the icns too, guard the icon set in CI, and register a stray provider test Review found three holes in the first version of this branch. icons:check generated an .icns and then left it out of the comparison list, so a hand-edited or stale .icns passed while the run reported that every generated icon matched. It is compared now, and when iconutil is unavailable the run says the .icns was not compared and fails instead of spelling that as a pass. Generation also renders into scratch and moves the finished set over in one pass, so a failure part way through no longer leaves the committed icons half replaced. The verifier was also not executed by anything. It still is not, and it cannot be: rsvg-convert is unpinned, so byte identity on a hosted runner would assert the runner's renderer version. tests/ci-workflows/build-desktop-icon-set.test.ts asserts the part that is renderer-independent and reads every expectation out of the generator - each declared size committed at exactly that size, the .ico directory carrying the packed sizes with each payload a real PNG of its declared dimension, the .icns walking cleanly end to end with one image member per declared entry, and no hand-added file beside the set. It was driven red on a resized raster and on a stray file. Separately, tests/providers/stepfun-provider.test.ts landed without an inventory entry and no seed resolves its name, so the membership oracle has been failing on dev and on every branch cut from it since. Registering it in both inventories restores that gate. * fix(desktop): abort icon generation when iconutil is missing, and match icns members by size Second review round found the two remaining holes. Generation copied every rendered artifact over the committed set and only then reported that iconutil was unavailable, so a run without it left the PNGs and the .ico new, the .icns whatever it had been, and nothing in the tree to say which was which. It now aborts before writing anything. The icns check counted image members against the declared entry count, which ten copies of one size would satisfy just as well as the ten the generator declares. It now consumes the declared sizes one member at a time, so a duplicated or substituted member fails on multiplicity; the two ARGB members carry no dimension, and the remainder after the PNG members are accounted for has to be exactly those. Driven red by replacing the 32px member with a second 128px one. * fix(privacy): stop the scan reading a Retina asset name as an email address generate-icons.ts names the sizes it renders, and six of them are @2x. The scan's address pattern reads "128x128@2x.png" as local part "128x128", domain "2x", and accepts "png" under its deliberately loose TLD rule, so listing the icon set failed the gate. The @2x/@3x scale suffix is a platform convention and an image extension is not a top-level domain, so a name of that exact shape is a filename rather than an address. The rule is written narrowly enough that it cannot absorb a real address: the domain must be a single digit followed by x, and the extension must be one of the raster formats. * fix(privacy): scope the asset-name exemption to the asset-name grammar The first cut exempted any local part in front of an @2x or @3x scale suffix and a raster extension, so a person's name wearing that suffix would have bypassed the scanner entirely. The rule now requires the part before the suffix to be a pixel dimension, optionally prefixed the way an iconset member is, which is what the icon generator actually writes. tests/ci-workflows/privacy-scan-asset-names.test.ts exercises the real scanText rather than a copy of the pattern - a test that restated the regex would keep passing after the exemption was widened, which is the only way this goes wrong - and asserts both directions: the generator's names are not findings, and a mailbox wearing the same suffix still is. --- desktop/package.json | 2 + desktop/scripts/generate-icons.ts | 192 ++++++++++++++++++ desktop/src-tauri/icons/128x128.png | Bin 2849 -> 1865 bytes desktop/src-tauri/icons/128x128@2x.png | Bin 6300 -> 3971 bytes desktop/src-tauri/icons/32x32.png | Bin 638 -> 477 bytes desktop/src-tauri/icons/64x64.png | Bin 1176 -> 864 bytes desktop/src-tauri/icons/Square107x107Logo.png | Bin 2268 -> 1482 bytes desktop/src-tauri/icons/Square142x142Logo.png | Bin 3346 -> 2035 bytes desktop/src-tauri/icons/Square150x150Logo.png | Bin 3655 -> 2120 bytes desktop/src-tauri/icons/Square284x284Logo.png | Bin 7155 -> 4419 bytes desktop/src-tauri/icons/Square30x30Logo.png | Bin 559 -> 458 bytes desktop/src-tauri/icons/Square310x310Logo.png | Bin 7822 -> 4863 bytes desktop/src-tauri/icons/Square44x44Logo.png | Bin 845 -> 636 bytes desktop/src-tauri/icons/Square71x71Logo.png | Bin 1453 -> 982 bytes desktop/src-tauri/icons/Square89x89Logo.png | Bin 1716 -> 1273 bytes desktop/src-tauri/icons/StoreLogo.png | Bin 894 -> 732 bytes desktop/src-tauri/icons/icon.icns | Bin 65470 -> 83642 bytes desktop/src-tauri/icons/icon.ico | Bin 11591 -> 8289 bytes desktop/src-tauri/icons/icon.png | Bin 13516 -> 9291 bytes desktop/src-tauri/icons/icon.svg | 16 ++ .../030_icons_and_widget.md | 114 +++++++++++ scripts/privacy-scan.ts | 7 + scripts/test-layout/layout.json | 2 + .../build-desktop-icon-set.test.ts | 167 +++++++++++++++ .../privacy-scan-asset-names.test.ts | 45 ++++ tests/fixtures/test-layout-expected.json | 2 + 26 files changed, 547 insertions(+) create mode 100644 desktop/scripts/generate-icons.ts create mode 100644 desktop/src-tauri/icons/icon.svg create mode 100644 devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md create mode 100644 tests/ci-workflows/build-desktop-icon-set.test.ts create mode 100644 tests/ci-workflows/privacy-scan-asset-names.test.ts diff --git a/desktop/package.json b/desktop/package.json index 7f168ff8e8b..144f8540644 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,6 +5,8 @@ "dev": "tauri dev", "build": "tauri build", "build:local": "bun scripts/build-local.ts", + "icons": "bun scripts/generate-icons.ts", + "icons:check": "bun scripts/generate-icons.ts --check", "prepare-sidecar": "bun scripts/prepare-sidecar.ts", "prepare-widget": "bash scripts/build-widget.sh" }, diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts new file mode 100644 index 00000000000..1670eb53518 --- /dev/null +++ b/desktop/scripts/generate-icons.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env bun +/** + * Render every app icon from `src-tauri/icons/icon.svg`. + * + * The icon set used to be eighteen independent raster files with no vector source, so each size + * was a separate artifact that could drift from the others and nothing could detect it. This makes + * the sizes derived: one curve, rendered at each dimension the platforms ask for. + * + * The SVG reproduces the raster it replaced to within antialiasing (430 of 262144 pixels at 512), + * measured rather than assumed — the geometry in that file was read off the original bitmap. + * + * `--check` regenerates into a temporary directory and compares, so CI can fail on a hand-edited + * PNG instead of letting the source and the shipped icons disagree quietly. + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); +const iconsDir = join(desktopDir, "src-tauri", "icons"); +const source = join(iconsDir, "icon.svg"); + +/** Square PNGs Tauri and the Windows store manifests reference, by output filename. */ +const PNG_SIZES: Record = { + "32x32.png": 32, + "64x64.png": 64, + "128x128.png": 128, + "128x128@2x.png": 256, + "icon.png": 512, + "Square30x30Logo.png": 30, + "Square44x44Logo.png": 44, + "Square71x71Logo.png": 71, + "Square89x89Logo.png": 89, + "Square107x107Logo.png": 107, + "Square142x142Logo.png": 142, + "Square150x150Logo.png": 150, + "Square284x284Logo.png": 284, + "Square310x310Logo.png": 310, + "StoreLogo.png": 50, +}; + +/** Sizes an .icns carries, as iconutil names them. */ +const ICNS_ENTRIES: Array<{ name: string; size: number }> = [ + { name: "icon_16x16.png", size: 16 }, + { name: "icon_16x16@2x.png", size: 32 }, + { name: "icon_32x32.png", size: 32 }, + { name: "icon_32x32@2x.png", size: 64 }, + { name: "icon_128x128.png", size: 128 }, + { name: "icon_128x128@2x.png", size: 256 }, + { name: "icon_256x256.png", size: 256 }, + { name: "icon_256x256@2x.png", size: 512 }, + { name: "icon_512x512.png", size: 512 }, + { name: "icon_512x512@2x.png", size: 1024 }, +]; + +/** Sizes packed into the .ico, which stores each one as an embedded PNG. */ +const ICO_SIZES = [16, 32, 48, 64, 128, 256]; + +function render(size: number, out: string): void { + const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), source, "-o", out]); + if (result.status !== 0) { + const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; + throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); + } +} + +/** + * Pack PNGs into an ICO. + * + * Written here rather than shelled out because the alternative is ImageMagick, and adding a + * system dependency to regenerate an icon is a worse trade than 30 lines of a container format + * that has not changed in decades. A 256px entry records its dimension as 0, which is how ICO + * spells "256". + */ +function buildIco(entries: Array<{ size: number; bytes: Buffer }>): Buffer { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type: icon + header.writeUInt16LE(entries.length, 4); + + const directory = Buffer.alloc(16 * entries.length); + let offset = header.length + directory.length; + entries.forEach((entry, index) => { + const at = index * 16; + directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at); + directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at + 1); + directory.writeUInt8(0, at + 2); // palette colours + directory.writeUInt8(0, at + 3); // reserved + directory.writeUInt16LE(1, at + 4); // colour planes + directory.writeUInt16LE(32, at + 6); // bits per pixel + directory.writeUInt32LE(entry.bytes.length, at + 8); + directory.writeUInt32LE(offset, at + 12); + offset += entry.bytes.length; + }); + + return Buffer.concat([header, directory, ...entries.map(entry => entry.bytes)]); +} + +/** + * Render the whole set into `target`, and report which artifacts were actually produced. + * + * The return value matters: `iconutil` is macOS-only, so on another platform no `.icns` exists to + * compare against. Reporting that is the difference between "the icns matches" and "nothing looked + * at the icns", and the check must not spell the second as the first. + */ +function generateInto(target: string): { produced: string[]; icnsSkipped: boolean } { + mkdirSync(target, { recursive: true }); + const produced: string[] = []; + for (const [name, size] of Object.entries(PNG_SIZES)) { + render(size, join(target, name)); + produced.push(name); + } + + const iconset = join(target, "icon.iconset"); + mkdirSync(iconset, { recursive: true }); + for (const entry of ICNS_ENTRIES) render(entry.size, join(iconset, entry.name)); + const icns = spawnSync("iconutil", ["-c", "icns", iconset, "-o", join(target, "icon.icns")]); + const icnsSkipped = icns.status !== 0; + if (!icnsSkipped) produced.push("icon.icns"); + rmSync(iconset, { recursive: true, force: true }); + + const icoParts: Array<{ size: number; bytes: Buffer }> = []; + for (const size of ICO_SIZES) { + const scratch = join(target, `.ico-${size}.png`); + render(size, scratch); + icoParts.push({ size, bytes: readFileSync(scratch) }); + rmSync(scratch, { force: true }); + } + writeFileSync(join(target, "icon.ico"), buildIco(icoParts)); + produced.push("icon.ico"); + + return { produced, icnsSkipped }; +} + +function main(): number { + if (!existsSync(source)) { + console.error(`[icons] missing source: ${source}`); + return 1; + } + const check = process.argv.includes("--check"); + if (!check) { + // Render into scratch first so a failure half way through cannot leave the committed set + // partly replaced, then move the finished artifacts over in one pass. + const scratch = mkdtempSync(join(tmpdir(), "ocx-icons-")); + try { + const { produced, icnsSkipped } = generateInto(scratch); + if (icnsSkipped) { + // Abort before touching the committed set. Copying the PNGs and the .ico and then + // reporting the missing .icns would leave the icons half regenerated: the rasters new, + // the .icns whatever it was, and no way to tell from the tree which is which. + console.error("[icons] iconutil is unavailable here, so the .icns cannot be regenerated."); + console.error("[icons] nothing was written; run this on a machine with iconutil."); + return 1; + } + for (const name of produced) writeFileSync(join(iconsDir, name), readFileSync(join(scratch, name))); + console.log(`[icons] regenerated ${produced.length} artifacts from ${source}`); + return 0; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + } + + const scratch = mkdtempSync(join(tmpdir(), "ocx-icons-")); + try { + const { produced, icnsSkipped } = generateInto(scratch); + const drifted: string[] = []; + for (const name of produced) { + const fresh = join(scratch, name); + const committed = join(iconsDir, name); + if (!existsSync(committed) || !readFileSync(fresh).equals(readFileSync(committed))) { + drifted.push(name); + } + } + if (drifted.length > 0) { + console.error(`[icons] these do not match icon.svg: ${drifted.join(", ")}`); + console.error("[icons] regenerate with: bun run icons"); + return 1; + } + console.log(`[icons] ${produced.length} generated icons match the source`); + if (icnsSkipped) { + console.error("[icons] iconutil is unavailable here, so icon.icns was NOT compared."); + return 1; + } + return 0; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +process.exit(main()); diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index e77df1e28fc9cfe97acd00d99810b17fd1fde004..2de806b6fe739e9c640b0262b964d52626ecf0fc 100644 GIT binary patch literal 1865 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 a=rBKLg=%#bB+u{b3(!XyAS)4eQU3xJL{iiM diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index c6a3f4f3219f3b8a3f6c2745205598ef57cd2e77..e1d4fc804a0fccc4c1e0393ac302bdb73ab39093 100644 GIT binary patch literal 3971 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 diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index 9299f3600476116e06ca002552e861f2bc32c65a..282567c56270cd86197f583896836abbd32e150c 100644 GIT binary patch delta 452 zcmV;#0XzQw1lT;LFY>ruPIYwTly|9?}C0rs#~$8ihWxXy4K zk85;?w#K6B7qsvN8`1nR&J>{@hi6#9YBYaQaOrV8cVCTyH$c)9F^3G>*9+C(nu{&wOqH?z9guM!#z}_X=Ddl&8@^J&sM>q-W zz70Rb;e8p%O@9*@;Z=&jlRzFcNZ?N(KTQ#MaLftJ3B&{{v$ZCc0{M4}z|TNlX^=oz z1I|;5ZN+sty0tnDIA51>6;m!oef5jc4;jUX`{lSdBZ(Gv%utTuqjEFRm-jn5_#WB# zFitVml6}TzG=DlARV~@FB6;Hk{)+c9TCx#|t|D}^Ha(?Rik;WGZ&nq_6P}Jq<8ruW u;!)0dn6wo~>V@>fA0JH{J!_A`B z{6?rh0P7)-qJMb=_raR5HN`!lefbJF1q*zqWadm$+d6b*5$ zG~2KxeOP{(P-eJFgmm33)mH+1G)=|rYF0aL!>(V)|9?+_*G4~HSOHb>yT1)WJj0gh zqb@*qEQ$Z6_^kavK+UiveRwia=#F`&%NVcJ3_H3lF0V%$X_1Qa(x)xdILy&GVI(Wk zs@uXnAZ^5GyG&o6EF85M_3{y{a;?-3(3^_}mBCkm1yI#)LOSdbiwBK2>UWF0R`oq)5e*XR;lfFUpndLxwO z=Unr{sAYN3he?73OGiFTI`(N6<<9Tg$LX)O57KR^4<>iptS)dAB1Ai{3&^iHr0K338unv5( z=+Qcpzyk0MICWHZf%!er?Z;42nnJ)mVzzxH7Cy}8hDueGubxFOA0y97zxCxvy z+_M#3dzesbV1GkzFej%)VBCTJc+_^s;9K z;9GJ`LZ~GCLgVLv6!}2M6OGuiO`(K5*Z6v5OJou?wxzFq*_KcW%E-gv2&mW)Sr7y| zs0{6!DdZ36e%8iI$aDLrSsS;=GvYnAvNl{38a~F&qkk?f6R3GcoY$)EM$^RNmQcR| zd`affDLcSN;k{YijivxdV|>-*$OG?%`f1%wrT~ly^+jBmkUVLJswn_NLj8B6o&1qJ zNqqp!0g#^YM@BpO^HZqT&QMJO`0oh;@NK|1AppJ&_$CCvw*lXT0QffGn-Bos27F@* zfHdd#xql8QArV0OkNlt3E|o4DF{T)N5C+Oaa&y>gy&q z9;gfTQXd9lhJSxiG}Crn33V(buHnTj4O?c3c#k_a8lM*$|4lJ+K^1w&_?`^`-PYJT z{`lo~IvX8@rHz+<2OItk&mX!+X zOhZl!cO7p<8rGgNuKqAl1#U;LW56X;R!_n)>JJlNQ9o(dfX@~^1_FUVAaK_C1HKqs UAXl?8oB#j-07*qoM6N<$f=?QP@Bjb+ delta 1156 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 literal 3346 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 diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index 7c578ed553729f8e81b1590f5594ab3b7581f523..6ef2495b7ddd26e108ac9c81687b90da91e979af 100644 GIT binary patch literal 4419 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 literal 7155 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 Z2DSB(7Z4wM2MquK002ovPDHLkV1kA5`Y!+g diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index dba929dbcd7a3ce954ccc5e743e8243c34eb7bda..0276815ce95d826e4a528681c4c27940d395f515 100644 GIT binary patch literal 4863 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 literal 7822 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 delta 1436 zcmV;N1!MZw2dxW`BYy>SNkl34#ejlnO8LZlG1+1=?|{5|@3o&CQ3 zcK6$CWCng@`I79s_nY_L%)EK?B__1emIMj{If0x&&MQogZGYh%t}9{VDH9O`wq>Q{ zdwV^SxxPLIWf~0QLh!7MfH|VSD}-f_oSl#}L)as{Nw`fo zh0oIw%L6CSWH9Xrxn=*42&vgj0HC?G7<^crQL&usFr`A#1ge?f1)^y`yAb*Y#UKgKd&_k2NauoyjJ_wq~ z#cYk>Te#A15Y*>J^0UW;6~Y>x|8Bu*ju3`~MZ!fqn=!H!1M)Upta6aH6OhQU;vr&; zwiqW|XcGZ(wIpUVaVH(KE?`0!2v@Tn3o2sJX^%dQw-3AmErj?Orh3RT#TJ6yBAg@q zBL3HUf`4)y=xs?3E51&6Lnto?K8C!k!+=+oG8BE0B?P}nJj`CVTUzEgpF+D-QJ z1>vfaNz3~V)DZDfeWEJLmyJY<-Sv{k4Vi>xF(c$=e_JkM(6T2v#_}&guXL36L=}iT zgWYv5Uc^)OV=-OtnSL~uV-@nnV$dv}k3z4J97Gl3(SjhvsOOcQ=AsiNpHKFMad9`Q zkbfR{&+`&C&$X`ex)Y{0);!_BD!p7CayWPmO4bb9Fk91uSmTR5ynWeLD`Knk$|fbp zid5%($*62$uVi~_ITrG5l~~9;^ec6(aZ~v$mfXg(BHo!|lD^Fp@Q!CCZ=ly~lXI(R z1c9kJ7%i=#TR8Cy$H9S|Ku#bhkQ2xWFLQdcD_2#%!-zujM8yb=MtlB)Ss3heh*r*4whL5H%d)y`yCAquM&()pEnbn(~;Ke2U1Hv^`#lBy0hV{7y zxl*NLtn_JApTk9uf0~zNC_~jDyMNbd)WzHri!Rr(#c=saQNZ*yX4^fha4){!`+8)Yky|y7ht)P z^cfaZJc~tQRJ9UTn15aPh>`up;=a`BYyw}VoOIv0RI600RN!9r;`8x1g=R$K~#90?VL+*RaF$ne^(=| zv`|_YKq(UiO9(gv8HnHqFg9U`DQV(BqaVZ=tvnJFXM|5+j3h7vDpJa<6k_Eu0d-`6 zKEXt*ae&h z=E!60%VHKd1?-I1_4xE4<~}FCp{vC+U=-LK$A)+u2Yx5Nfg$l5Fcia#s_8&}y}1or zOPmK@uiBWZ=zjx#CcjBl#81dg8U#Aka3)@n8it-I^u@$(3-lu0I+Uq!D5HpKx zU)G7;$k?IvZ8Wx!Zfv`0!?A@#Vg%a{s4J$3lLir&DQ6ZDN&{I^YkmAErie=h0q@2X zkbq<8eSf?V>*Ag0Pc*K_x_Dx=*~UDu!J6!qfD0c+-vT@rkgsIUEA1PBcdW@mOg^Zz zH-R4l@|Dc@hR+m3H$wAjD*C z^>!~HR~^v$V_waMn5@00@H8M-9eAR&n>UL>On=s977GEn>cG6xzR{W}#N@iJ;$mU; zBgAB ziP?`3lNXhC^Cosp(4GXDA5w&vJg2l*Q4?JfJxcqmHBpGke=6;zzUbX9yOnm$k0nA( z)_>kZcsn3h9nkut`LRTZ$vAW~(t9vDDM9acm0V~G%xZyG-HVL-l;u@vD<#JF$= z`dgjjF)o~eo2=f?IRf754CSJ7@g`EiS!doy@8gd#1q>SmT&2G6Xf%Lp=zTm9Q$#m1 zKh#8w#1s*ckJ0-!L*$wfu+{L*67XtiA%E<_R-u}tmTv&5XVb*oM(X40z&7Nx0_wOP_Fb|OJ!>Z08+6ugZ?51g(JuhPt`R8e+4N2%n zs-HrCB_+os1@0>LBKO?f=2Q_^DELXwYrq%CXq?-qcH=uD?yyzK5K`5d+pv&$NXBVe zyKDl!!nOp@8H>nDuGZ%%_apO6>tDn_Rm>opJ;e=fYoQA{@#F$hh?v9JIb_5A1PQh) zTjW~iZa~h9IK+!H?fa09S8>|wW5m#vXWHKY{;D9q<2a7vIF92uj^hCT0-@4)(NV0V QTmS$707*qoM6N<$f}IFj`v3p{ delta 1701 zcmV;W23q;~3A7E6BYy^aNkl_#2nEeY6c^pO zX~9Jm(Y+G4-3l?#g)UrLu_%b5pk`+`E?g;w5)p(nNu$J)MHj|Ez+g!_f5!XBy>NNV zn|bs9oBwB~_rjrR{?DIx&)j$KoA>U$zY~a5E*+sjNEVVsgnwiqSx6RXEy=2a&rBa? zk~N_r{;P>dDk5Z5*BQC>~px*Z`~neg)h!X0q+3H-cocTq5^zz!k*vfJesQ zrv3xm0<0kZW`AT?X4$GjUr*#d4Y&$;18|zXhm=xmEXs4xOWDtK(@72iUjse_Y-fzS zwj^`wJp;G~c!ymlHiFTpg$KV5``vZGyMTi#tlFod5e`nvtALNu&l0)On20S3kVLlj z-;03nGW4kh$$Xl90(c95e&8XHje_hLS2tR20N(cLK7W))f*%F^0C)laPpgU|Neut# z+kL4L<491SRlv)De|-?GJQ4gD4(v(5F<`<`p;5UIat$swp2q(t8q7b#60JP=U~R#_ zITB1(%cJ~Vz$Rb^u!j#3>5zt_A8MunX94E`v&O2X$htYv&!R;S?4#8XiVzXM;2qB+ z*bFjv0DoTrZUWXVPiDSE``Tv!mjG`9<{X1D>7!@piy3-XPBKj&3UYY_C(LTL084;x zGiHUV&`of84X_ND&lsPLXbOe{xs7NDg7q@(eIqiP60>W(hktWBV{V-Ua*}EjnvvrcB>?-0KKk-9}iFwJdj035!o~yYf>u^-Yp#!LNhdmo+Dtk6DlNFBS~@ z86=XvdI|U$uAP-nOQxG~1-C0>i<{`6D_LfH7flcv=Q55zYXw2pd>VOhHd0rT6GpPl zm(vtmVo^M82+4fwb1CN#RvA1bz5*XPe1Cmp^ha=VqG1L+3KnJzNf~2=T zVR;HyC56kmu!OZQAi;f$s|fH>xykt)9Yc>Sm(fQa7F;;r#)ld>c zk0{42_FTO|SRz5>yyZg^{M}uA>=PJeQQ zdTbVuIO95NL&u=kW#@IuLh{ZS5@v!5Pw;mvB-86t$V*zJv+ELW5))7INZbqBC3^)o z;S}=XEf+3wc<)imNIr7lF7ipTOX5k|GLlmdoJfAMB!LUbB0{o|EF_Bv$wIP-kSrt% z$s$6skSro3*NWuSaqF*5qC9X?ihtz3LpJ7Q%SfJd$PVrM%pf|#TIIaW?CF+~JYBGv zf8X^qD0G+-3QuqM3l0{~w&a8)HT1C^9PcA9UNeYx3w+l(JiYBV*gsQ-P#5*d^Ub?e zWDspwES2(kXM>X}qoCm=2dKxak#B8U@ z`&>cs%0Or)M3LLYtw7U3UPVg!#{k2ut2B`OT+4^d!t*&8hb;^iaEXXwPFpK_)1;d!VK8mhd z)Fq%}Tz?72l}9kwZ;^G|N~>Fwtvw#GeU%(q1bg2 zwhmq~YVo3+vfwWEyZB(xSty-bYTUU+dJsuBt@d=&lD=@#;jmpD4y9LT1xvW1^FUhO z-9Q)8V+6Xhp~UMbOM*2_U-FwFW=5E=A(Q0o{>Pl vF~_S!fk+_v$wIP-kRB`~3&|ovaPdx z)m>dxZAPsxsRcEa|NQ^Z)zxzvB9X{{!GSKMMIv(atV;gu5oUA&;b>LeC0n+0uaILBkck#P~%8p&&fvq{) z&3+$vl=JxtumG$f8+f-s?=W&xI15|?CUVv`fv1)}bGeZiOMhcM;3aTAK#ehAF45Hf zGgH0=j-l^~O@H8~ueic(U`wgD0Sx&FKUPFHfeSvO1x%ucdFU!w2Y!&foNoC#wcrlf zARS=TMXafay$E8{j#og9d>b zaS#XORTLDt)}M9Xs)HqsV8d1j3{&3X>SbjNtrw2 zY{#9`%G`Px0qrq<#ot$HRO1|EXedH+VLV##TB(1}MW{x$ zzuQAvHh)z!O*TXu$L0e_cTsBxP5L^`;UaoFXx2w`7-=Y{MYoWaO=zZp?@GNEaMV}) zDtgOw4{6zq`xZbp_Pn7+7r0y`VB5rQ0#9;2Uq$XH?~x{Uw?J>diG2<+nRhpA0M9Ia zTG7|970NP&G$E=E(E_ei6=I(u@tiZz1T(kF1a0Kf6d@=PK0_r rJ%AjbrzrP&dOaDPKt3Xo$iL1niOYk{2*)2k00000NkvXXu0mjfTFpnq delta 872 zcmV-u1DE{V1^xz*BYy)$NklfqXRR-hUAytyf z3ivGo!N$RmCrBHxT_t6t2T0*2Zd?Rh2;s(!9zX^OOLv)tZ(vyO>P~m3NStorYtHu0 zd^>+Tvktt1te_-PP$E7-0rRW~2(v1nGAgezg)&+7sDM!Tzki4@fry?Bd(h zuz=qGl<+0a%~S#s+MRwFLf9G|!a3n4WBP3oJ`>)Ec$0_q0dD3bqhj2tfh5$3j zV81fzK$<1%&=G=N8S);HB?m&cX9~NBc*ssQ#Do$g4u3!*0D%Z6l?LoW0ror)R#%W+ zgDy^;)eLKF!{?Q_MpRHcu246|IbWH0&m+{7sUQ>~rusIk_%p207FmQzbc7}HCc(v# zEz=R#7EbeRm)^d^XFXJG&nQ~w8EZz;i=>n#)AOOBqmu~7z$UI8(gkmkos?~(92ckf z8_1OIzkdVWB2#zY5i?ux*HE0$^&CYs>#7(sIYu|2+q3u%97Gxx7wRpcU+P(0u91WY zud0Q}a0x_)79!PoIz;8I?owsXUJ@yZltfA*D^Da^VV$E{4V1Y?mf}-ig3@(MAsi(k z!fXc?BF!ZbX+n2XsDBtJ+`n*?J$p%H#`*_J_BEqC!`Hz80000L-rT5;WBB)5OA|-&d z(52Vh!}s~!=lDXh$sjI z)r-{ERU;#2AO?XzWE$!!hQK%c>VgpfpC!)Tv%nYF*HBFfRMF401|(D*%{3nB=z#cu zdl(4((isH3Dgs;#zy$)~<$*zXz&H5nw>-#y=EC#v{>S}Q!FvWuRY37-4HZRWfAD5T zugi7C>o2ISR|TM)k86fr!|u(v!mYb#sdkx3Bl%TDqVnRR0!XR#9fDCr9#le^7%uE5 z|D!WP;!pa8{4+)&-eq_S*;!lCshuslV^78g^%J|`57=-EjA} zW~ao*q=9&4*szlhQohs>f4A@wDzcON94V$I0x=7Z>lMGr_}E-~^PrbAfPXKvOenk- zEL4IaeoR#K%oDQq=hcG|ItBbkXz@wB8Wmm)^&`pVy4G>ft7DZw0e9JrAn1 zZAxurd59mz|M|X5sr|R3Eypeddx~dyi+DrO{c&92<`^R)GT8F-JmV3U1N6gExljNN zyUA*cDxwFQ_cJuyXsL1hu<`5;OZ5e_+ljREJ6qOMlNGUZk_-RNZuW*;bjmn=hxr}X zG%5@8EHR6UyfD}4Qswrzk%<=$7XlKTD#$zCsXEaIGyad70kgmR_H53>FyG$Wy9#QIs{TQ0`a1D z?@E>rGYU(F+!uJWPjG$F-OJ*=gq_!EjX77o1lnTHvh!M$>b1h;`sKz@_9yKxV1p}; zxvOFUBVAG7zUMQBy#lS5#vkMt=o;>y^mCh%&p0VVAKMOai^)6oN%M2@zqF1h%{dHt ze4Z*Fby#mLK?;^mSzx!fH@j}S_gXjl_<>dYeCVw3C3Oh33rar7Ql{I?f%0|zoe%EC z(uFD~p+v#$mAt_am4r<5U8#f$<}b=3?DsR@O+=^fH@Ykw9^)z1ORK&tj5{{str*M4 z>&@S1AZLhojJ_*fHZl9fueglu$$6L5uC+-_odLc3#-{qi#7>jV@@Fdx0d?)7d1{E@KA^%9^5BR`;ne9%8amff8#EX&b?{@wYc36R%Wkw zPn#OeNB8$A#W_z@A2_4Ft44^d?->7te)Dt2_iS{F+rcmmHu1~YJK3r|Fs8i2#K>MU zTx*Bd1b47?w3O$9Unepdd#n55u|IgVM2vtCRT9WDR91~Yi@K9X?d%EvAM1#NU_p3? zwu8=!ExmZ+_0=NQP}NnbP_ldG>>w-w0ufpLznBvwPz?ExIr%Ja-wP!EOUw!K-^|HX z3n2A4s2^aFI{sx&thHijRIh)*y^N1yy(Kt!EwbnxRVrjpMa9e|iRU=7$z*8tJB29q zll-AB%0;EY$Qp4$s5qD4U^asSNf(SbrsdCPRs(Tl&YQM?HSgKQgYuFI>#-0IA5pmr ztnYU5uQ8Lxl2&f**RQ}LYy@3MI!e3;aMJ%}LSKhKSADWTTAyr#!xk>a2@7$pGX^@gPE=gwDJ-eg5LeAeIgOZ zXo^P*PUONXTokH|Q$Qs#PP~C`8FRK0H3L3$(%bV|E^BH&_>tPQ>DJ|%1 z9_j7atUikts6>6ggeX~mwDP)7Aq)LM@F0Od@9Sp};$2++2B+V*I3mfnWOp!&Cc4jz zVDCkfE@1F%Wyw!0Nh5?DoH1J=WXDu+s#H*WQ%K%q4l%-p*o`na;27e_L31a#Z{+dQ z5K;3AMR zZu)k~6#LhclqS?hkReN{cNSi?m@(}OZ@K@Lqc3Xla;qZnLOd&U;Cw87-M4+9J+e); zi{rt3Tmw0tpsp9}Zb9j%w!K5ZC~;$^wPvT@7+UxA+mW079m<$Aq16*WG#EE zd0Iy-GS5zww|P4a1&AcvUA2RH(oU~g`laJR$ni|MF>!Bso*&g_!)pjs&jjIa&D37!3OK zR?=aSxBD=t?~auHz~lKpW(7hAchNf^RP8@3mE;u2;q`h?eRm2wdmANn_T0d}B3bQD z2=9vz)Ae4wKU;D!)zj}AHNFZ?s}aPcE2x}WcgrF)I~cn|ABs2GYb$UySK^tsvX*)~ zB$aB3GYzi3i8E9z}aHK9d(qu1m3pe~Qx-YA>C}wPheMNIn$oBgcqt)$c zAHBD4j;A4~F?ZrTUI;%bwf4CyTNr+$xUnvJGo@qsy`_S-PbwW&tPGUTm-|W34=m>ZJxmHeMFzF7xW^|UbMwZU{2;O6r zT*sPehm{84c^@6qO+~ubc+h;28KvK#wFYa-z9o?2W9>#tDR_%Jt%|P{oRVSWwhH-c zk6-vhR^*h=yPuFG;1&FOswkfNU;h};Np|f-NjfhN5>~>ht-Y1#*BmEwz)hpP+Tie~ z9&njn<<9$8K#$CG_km&2ioV5+^rTMy?STGn!b@JIkbAq_IJG|R$zUun0^<#|dXi@7 z2&%ViOL1OX8}}nkc6k`j?{{I>bs#s+Z??Pf%&Oz5NLuNBAV(m}BMYIo*M9F}FO>~j zhd+Ws#iQmJi(0J+Ay)DX^|!bdn*6;%Re>T3Vltdu%EaRlrJ=t-{GPv0vz{R~EoQXH zUkvV$!L1dTPEhPlLTt8Ad~S*_bVExe#4dy>`z}aZ_AMT-TOvu!tavgARCv$R@}muY z)RI>bD8A*$k2Z`!mEl_j1lDMsyubWGqeKvB75nQ|n+Xf`;N1z2+^kSS;S7d)foD%E z$kD+}bp4T2N)k(wQ;qjdYh*=N8_?z01>g24zS{4W@lv-f@Qv-+dENcVF-mF4FWpYf zUMtYgmt(ZpT#>yZc-lu0-hQg0y$iH1`pcnP&Va$aeQSj$OzbhP+xRsvnpkSrt;h?i zl74Vn3l6oYsQuclC3LigaPaQs-(e5kB=wc*srMnjzD9c9VoLgnml=_-yNx$G^rhix zfMj_N$-Z_}TF@N%<{ZA{1<~y+YTxmXsKpL0@`dedd9i2xI83#z313^Ve)5NY;>=6hbs($1RvyvsTZZB<07dvy+!b!5w%L*VJ$Q|APkqAc$AbQ8-Y~&P%xmwbESxp!qj!B_aj_(WL#)*y<|k z0)zg+R#rYmkFHYx1)&-k^gpoGRSO`H6P7^(1iF6uFKm_VT*j$N|KjHhdo#L+aQIf0 zDm;a!Mw+L{{+hZ<URAe2ZD z^#8b9R5h$jVccdzoDpK_vLg3B(s}dk1apD=`+$)tMUR--5VrTSW@TTKA1l?dh-CIfpshI-{U|ibYj8mtO@>j_N#y)>#X} zw9KK9CtZR09{CHiyv(^a8BS8IzHWqBuuR(8zCEsLrTC2kpKkY!;NwtE0hmy!lYLcrqZk zySj@_mIECFqgmCP*H)enG#9yrIFUWll)MbTfB5S#qbf55sbR zieTQrWg~ueoT$`<{sKFDlXwtWNPBqLAfC{x`Oz#$?(=DF=4qo z4~n0;z$%dE$#J6l3!|txvIL7xEHW4W+xHM^tcZ{9S}ZC-zRcb za8@P?N=M#L`^Nn~SL?FprcTsMUW0&%jNj`jQs{ZWYMEZI}zjUn{|sUkAJk`PtB8;N1DwXvgb84DB@zctScq~pw`wLd-5L#I;8k6lJS zY>;x0De|Czwdg&FgdL~a>kIq1hZ(y|TmDHWy73Z;8_S>%GPS9Yq$oLZ^|tyjnhrfL zNFnSdz`u2H_;M^|gwr(T+H;jh<1AXcdCpjWiW1NIDWAIMsvMY=6S=gzJTTtIvnTsZ zz^E?WtconTcs81(70x|+gF@Tk8tj;1or`x#QNF06cPx&G$dv$$6Sm?Yi!%GZDN(}A z($(d{wL_MiD*D<4p;JuOGqFMYWlRmDLUZuin5H%9SBFKeR?%iH)unX9q439HUhca1m z9~%>fR zSufz_{es$-4$fSrBwFV}HGbbrHY~YZ#(zLZLN`1ipC(L4-ZF5v%7gkiYg#WEWHs<@ zrdw|MOo5B^MeeFY;P#IHlX;^flvaVU24-9k&fL?ZOif^~i3sq45rC;D?`cZyY#fkR z(?i?hY)S-zKgG11P(ymSHo_F3NBl&`4caAAxSWk}#k?pq{#4q6+T&*I&zcgj^~}@2 zTRS$=E~ynt-P;JNop5D&Nh1!F(ZtUT@{svcmBE2EN+T=gcyx2*>B7QphS{vmuip2m zRPZCiVcFkCHn`$n>_Q8>Mc2;+&>^9%EsU%dvjq7vD#sn$(I3oJcKJ)3RG%LjJ-iRK zc`Y-iiAg*=*C4_=2h)VwNIz`|Q9%e(wC-Bxi;(T_@x*IXpDiWF?n)Xd-!*IRZ>m-d zlYbLt{L2Qy+YJf6H?_vC-yY+;5kAL`(>`_HMx~W5_ z3#2cz^H@rFqR}HhE2%_Qn%<-wUJh-1TSFezVjK30cVgSws-C*zCNK8-D|`-}12)1A z=X-<#FomUZZik_*+uE8UZ;q=dldy-cl_BqlGzUn<%$LAGS7h46w&j}@B) zZk&ObmW`vh-wHC1gXF`}FsR{v{`DTE_oY(Q=*#5WC7z2$d|15$8Z`}OPRt!XSvIvPPI%g$>#H7p|!a<7>+$eBT2ZI*GF#}`3( zodXj$s{|pJ29eIyVb}S861bOpLFyENg;7M@2sboCuLZ0F`dbFYvoXN?ng7aZe@7FujGIA+{9cm?9GcF zP5f5)Dnjwe{gx*ZLLnca>Y$HbC&7>< zN;Ys3wU#UjE)1rE3yVHQ!li(`r7kKxHcD+IlrkoUnN6P@$djt>qJ}}K*}zbC2g1Kq z{cjj6Ksj;u?lpz}{=?f51buK+JlUh-*aiGb%Wr8k*DZBGqpCq^u4T9QmC009u(Rga ziioYDwmRH|=uNCDbsFQ(*5riVN`VeQZvx&ox;;l9|5J%VRC3OWCWxQ!s*-?zRq`+0 zdm&;5F*m)^dJjumgCr=b{=c_Nr=XVy^E5xZ?@5Q8_`Q(MZ}Ou%@J~snO)~ZB*CNP_j?3uLSnN*HU8My37mL*KBXc+ zdBd8vn<&0ua|I`Qkns_^8I>UqJ|TWildV3SDEZ<{!O^po>o+<)EJE@vfC$4iC;q5a z`RSSY*VGe1=qapVRYvO+yFUGk`nUH6=~)(bvc>nIw|@gvc4x)XxB6_Qy4L-1=Ft%! zzYqs9_-W?Np+f;^M_Qh3;5Etti_N;*DM!_{8}CZO5fGV~zGqoSb#pxhlNW!Y@=R5a zeYQkEtxuexosVubzq=d!Wcl9Z3p5B)=zMmFCtvEj$BDQxK3=n2I$!NcDE$VfJXKfz z`mvuV zmJEct&pR_kJO(L5lbdYFfs7abaa7j#lkev7+{1&U@0r>dS{JLRa2)+ut?C=a-;aOA zQ+@P9%Hcraa0T#Oyq@o&rmP;X!A3AkE&X zCM1-P&xBIY%zC}ECh!u=20jAMMd^-hdu*~TCRks>Kfxt2xy;Ru!PUkPr*GB9gq`aF zw#97gJH4fq{s9HX;9Z4>`_CxMC5V^=8qG9|PczRN&Lrc$*2cf!X>$@g(R6uWv&?JmEyOrh{RQ^?mwx;gVgF>8T!-}GlI$>&1)D!A$C7pi!5WF41ctbBA&#Q}J7NX`Azz>JpoK9Cyd{CIhTu|4ATf>LbYB_w>ObaXCi$$mq}qtTzeU(wn>J!7ssM6@+Nx5QkX@nSGDVVzK%>U zf-jgcxh@0giQE}>&M<$kzVKk)@vZIngQ}ygH#u!DIN5d(V)O#sjL#UbfWJNGk+f5tJqOq#PS zd+I$S1mo?Cf3lp#Dy&Hur0#y+@Y_=938f<4&=0Kpuph@<0DT0aVtcycFu=rB01r&j zB#`WbuTU^sIMyG{5l_p+mOu%$pZf7h`$zXcSegm!m(|=w=9HAJOL?+aR7>kB&St8@ z(O@S`wL|ZZEP}h5_gm-F-VaLyA%*jt#eK`Y^|v`GrUMe=4hWo4b(#BI&Ys_L8^7h~ zJA^!1nXDhzR0qB2%zg51-vWnHWD`X>8DAz0Zzilq+ME*%xvCp|oGDqpNL&eZpnRo) z4%uioT&D(&r;IesXth9BU#=qq{(NP=U<{x@ ziNL1cg~)Q}H-(3h-YEa|htf z3I+UTFl7fHrEY7mCYp|#3#vMDcqL&y7L+-%IK|fm<~0te6}+Qp)X}y0l_`>|cKTz4 z9{O{btpv6tw~meb905`8Cfs(GM@?l0w#OfgPdTftx&ooHT$p4_DGWe7gdTtg)_SDX z?c##vE&tfvjLfqnHRWBt9LwdmXXeHf4#|4I;us_&Fyk5b-0;usxgdtvn4W?oRrI;S z75E!+`lA)?*uVpEviJY|5Ta6~CuK1d(0kK%7fgUkfVq`8svhMEPyF!g{J+k z#uGAW=&URKjZ)nhgUWKx`B}GpEk#5>sqK=Ee@6LP9VAjjF68SQ($-oB&qxc+t~9gN z{E?Z`>9A+6{7bDy?%sl;Q6}wJutlKeY3gq^Pww}{Yi0xOJG}A)|L4+sfMj_;p_hbx z%gqB|PyUQ6HuT^2ye0qgvVVEmzr5^UUiL39`|b8?f76Sb|0`Zr9H4326oFHVi6H!If?{H~DI-7- z_PTQv4su?5>lp}-9lHsfVZlRfuF;}Ekh)Eda1exhzZ40undis|5MlT^1qdX7GXidn zaRMNaFis!1)yE0{KW_i60OdIfXyW-9IZ#FICO1$W-+mDagm-Ig|2fhGY8tV>a^o2Y zLR<2W48^n&Abg73fM67fAROrIKQG`|CLsIW)&KcjUkK18$bY=+yYD}G3Z(uk-t~q2 z+q=G3Er38I$Mq3_1e*VP*LQ}n&fV<3(2f?7hPpaLW0ga~Kwl-pE?+IfT)*p?^!3*d z7(Z|MukN(p{M@7LD9ila=-QV*RcvEX*WW=X3~uHP6LCtg4J)&u;OdwQ1xYns4=KNG z--+3vKB+L@nC%Upahvtb&gStc-^v!xTqPt341vNZ+2DU~{%;)wR5OW{>lV@XcytuL zN8)a7wxpPM;6e;@{I3DM{-8pxigucVw(kd2+#!eQ3Yk>L=3K!`<6T01i%wK#oPt_b zImh}7c$V?3(}B~W+i;ssg?vgnLpY(`*GH@+!#(dCY_pEvc3m=i>*kncrRjIm9Q&7q z4w*82U3}UmH6WxN%$HBO3Y5?5eZ<^gD52fwncAeapw*BQcAq8W8=>NTi@c8WxeR~h zh1f_lPq|8RWp`(NnE35Hyul}XWZCDt#UUX>a}NAlDLstb*CC}hie?BZ)z25l7x4q8 zXUP%@iBWfP5rf%Ec3TiL?vG(w=ZJ+$s!EMXM@p!p4MZBW=Mq0u+pGv; zsblw$mwt8XcXMZJbRk0KZNIKQ%*_fIg+(3VUrqyy?He18sZ#(u`6N_vwiBUt)3iSw_pg}fjg$tWOK#ZyZ8cKN`VhLgGTdbw(eB-|~9>fAGGX@%c-*%4$)#5VynMxt8$w=uY z+|Caru{4L1*sbe&`IL1ujA+9eD7O7nzPT0M{%8BN-Zt|jyP~l*8Am0V1>d~oue;Dl zEu=6Fnq`M0(N<8b#R-M(x$j%uAS%)ycq2dXa2ZQ$^O1{G#3&7@8#wK`_i@6gVsO0X zxynM>{2dUbc{Vyo6gzcm$2F3o(nGXc_8HJgSb1BE^eo@I%OMqaY$g8zFBDd$hG9?F zMnH42j^hushm8ZN2f?f5suM=JEz6-7QHlCMM6A!iLhM>ozxJQk-Lm*DdzXt;i1#&wT-Y0J#9+uCO%dax5XXhH z2E#VCcr$moBRGZu(g3mw{~XN>K}g#&I|uj29m#US^KPU3IA?G<$Dg0eP&JG_@F@GZhPBhJ7kjhf7^P=NCJdbi! z9n&`YK4r6zt6!T3dMHJJ*4Bq0nrBzIx6-RjcwodRa(pb|oHZ37LI<-v+fe?yO+_Mr6-J7Ze?g&2AG> zN)n(td!1MlygX43aJTt)x_EB+$#J)PUM??I>K*@4xT}`SB^-smFdVXQ3hv>dVX4oN z23<4+&y*Eb{63mivF^3K3FzNjNTDOzi6bXRGGK^;zT!5x6*T2isao5vLlA7U@H9FU z?F9%e`3!COC)8adc=6ih_U1i&H}K=S`KKCZjC-ACT)`*X&wkrN0ma!-rLWPYK2M2I zut#%2v+xI1#BL`&tX~@7%t@5A&)1`S1-Qg$qS0*ldQ^VB>nl-A0tX>p3-7|mTEAhjpt>P93GECa^P z>;`%(=8;uC>XFzp)%f(~NgjkSR)jrKe%$Rr+_NIv;yt~(gm$6sp}hshYQs5Voudy`fLi2; z+Qf28fHQhQr*#zNDwrvL`qG3g6HNCBX6{NCOa@nAlxgqa%mc>j4Vir+%?7^1;55D7 z+!SM*q~nE?0uE}RGumAGgklrY6H+yZCnE^A<0pj9DPKJkb83-LnpgwNdVu@=R+9%| zZyPF3Xc(T8rL=g$ndw#^C^EV|&!{6uzhGp0qab3C*=;rNDd)bsru~8ZUdgYt4iA28 z))HQ%9!Jt8zOFjW5w}Nb(}n4@Nc@;om5Dgal}D&oyo1X=-4gFGfh!-+izEG@&6YpY ziK_!4_lD7jk5#uE$GaQV)k{swaqdF#%;y#QTfj<1WDwU@N>k9=r+vJo*N#VG4qK@_ z*ydLWBWbx^W^1x^U3a?D z_@FPSk5QwO-ybrBOAj1RP}`<9g(}^wB1UC2cF6BCMz(a7FZnTq_jDQF6plfM8;{Z``8+q*f>9Rs!r>IKUd!Pg-(B-6Zi%HG+Q3#UjF z;k|I_oX4Y@(BOeCv+3_G;871~28-JNw3)@Qd;5N_W#hRZp5`$rWgjeOOA+(e`oWS~ zG}3$>;(MdW(l5+*p`NJs#D{?JfoY*9AGC$K$d+h@yX^W6v7(U!o7Y8q5!vNb(D%Hk z3@2AD6Xw?zdo@d;BH@30Fdmdi9netTv1%&G7DacZ@{lvce!Ws2A?jBF6`~AT3ybSG zpOF4TBq!b?O*whvI7N1s-|i@)^Ke^1%6rdUJRT}d-K2^&aw?V7wL3y6mh*QO*EX1C zE8=(X98}<0ou4YSeN3({wmo0rx(;E7jXFlUM#B+-<+S9U`h=Quck?M9o90yr9DaBn zajqYZUBY?473?gbq%**f7_K>SxG~EUaqmd*j!ETQRfojP40pnR6565avfssV*Pq4^*mQA89u*^}F0i{xJ^?<@3ZgZ~yW$ zVG6zJ{b~Mnn;H^rfUo<55^?d`BBjjeaPw^UT@*bMO@P0Z<}6e1G}nRW-84b)>tg}Y zK8IJX@$y2~YBtiMakiuIg-L2C6l>U7sTtGpdYsI_V^XiSga~yXmX#=QDJ*h&x|A$Z zgeX_CGljhKJnfV%-<7L%x%Q26RS%O;JlsRY?gtGm>wIMLE(QOP`{WeDgvbwe#d?fb z0RYRDvdB6X;k8+`o{0rzw-PwLVe>LZNsHFU=`$nj&(}w1xNz_Kxd_;Vi{YD8{x%Or z5BlfcZ}k(WJxLL*X@>=S%8M^3nXeow$lKo}eWqlG2SI$_9pJv#o>XNW$C$RJ{bvs% z(LX}Ld2bywI@C0G5kN2inDjijwKecp2&Ciq&-y3xS~VyHcj`vN>V$AwhAVpno_=H8 z!V;)6u6zpadzP$njviz;b48$ASuQr&bk3+CHSF0J2k}Y^s>iyRr{9j@4+;>5)*Nf( z0P3~Cw@ECHt;=hdI%r?Ah|q}vp&ao&QYhNwMTT3}wo_F_Wl;{-jvIpg3-C9{iapEC zuS1~ALSAnQjy)q2#tpXUBylG-(WL8BhqPpMUe>Q$a%NR~i=ilQD0Xx@OfzPf$Ug{+ z?O{@D= zmT#dI5fh)6#cZI?gXf6x_r)u*=Oo22LRH3h9KG#CAjXH z`^njV9_N}XhBKRseLA>YH&#$P^OL479j}5kYW7iZ1a3pEQlxXz^w6SoQYXFjncOGoGR%?6=@{agZYoLj>=3 z&V*xqMaK!#?$YQ7v?teQ4sDuVVW>yh&~h8EE2Nh=*hyZ$H+W#ZZt6iPrQ{>jl;7bu zl>w68#rXT*U@*$Z8hPX?>jBwMRk2R|a4p~8+P)V{{j^D)nitrHw*cl`gpG>g&Q%7o zk+yV&Qe#hOAc&i>(!v!J5e2$OGVU~9-5N;r20qpW<*Bvb5?booFe+^&^$ckns)qa- zcy_kukKGCvKbwo^+F82a?!%JtUmNrCd> zMxLDvUD{VuB?f1gfM&qlNJgvSgj8}*A=tPN#Ha}Tt?ZjYR+JT*?OV-N&%i{KbQ&1> z;*GILmWhxfy5gH-2b0jts*wn^vq;Zf+hcLmEPi17{7=^XSudK+mI({c7 zLfW3;7BJO$MyN%jN(s(DOWasGxo&{`;|~Z1j3QgW$xhb+MM&$KAGRhpUSsqQng$Su zNh7Z!@Dwi+3)(l<)po>EeI*7^#pP|>X8Dojktl$c*s6-qBcgVm>mS2x>;Al8G{Sf= z_wR4(W(T&X4W;=OTTq*~!?St!XH`72k*yUu71_L#_+EVtfg5*hpYCG4i&d?ujQ#_d zB9hU$)=w}mUX)tr6Qh)&F04ap)N%*@IL?FS@tb!J2mpE|PEcQg(l&e5IosbEYeXo` z2d%idQywrj5@SPnDYb%Q(AmHTL$rveSIuV~m-f2=K9WoxRlU`#zQ0*8MYig&NdsgYjtE4}7_r`!%6$0*GSkj?c7&;82en zMLJ5{2yuyX8||p zy?xUgSnWJsPZLX`p$9cNWit%Ks1S))iq>2;sq|;xrjh^{$4}|6+5NLOutxA`cEog! zM*N8G-FZXYBkbux*q%s+4uJj5YvJzq%NC;p*Py|B85fTvqtNq7pZHn0mP2d-i$BI^= znT?KEI?hYU!^FH~N~FP%b!vYDuCd<0J+1McyPdqT9VB^$Mv9@1p|zSFC{|Mn$flx-MkUE znSZX!XF&4qD%>$48EskiGm}fW6N|G+*R`for`N_X8qKi;Rw2CO#0Xtw#sGL-3z7Kt zJ4lMtYjOqzRI|FI8xWR)zl)b?O`8S?3czcjgNssK>~yc+$KgH^A41HQmTTW0Ub|aV zW?@Ac5W*y)N8E0xWZZ8!qs>+KsrRD(yKxecXo*ifAuQPYbC@q z;bU%GbGgU^{x)Rd($>XPLKsjRD*umB-L3sYv*OgHbR{v9;3j3l+vky^^PJ41$~1t9 zw8fko=L@Qj2d>Sx+85s)1e-^>-o=}cBbArWbmIUz^A*CXwG<@DQtW|{HKwn|8&qcR z^eSpLb|j)qYlTy+7qE0Nrex^d+W-Zx87l2DHQj9nXa>r(X!Kk^XM)35hbC*#J8Pny zrXNhH+!cPLV&6U}oX&H2-TOoj%Jd)_;fC8;=7nn8BuXOAVhLE+C113N*;!*AzY?9g zf&BHs#Pae_W%?f)BHK&Oe89faqAa*C_N)?rI~CgPR{Q{*d?_IEhpSwV&LL@#laPSe0N7RR7yh2M zjwuvID!5ZJiW7@vTm^~GB3s|j1uk~TC71eoT-muFI+z-cCqvVT7NWTqTI6&4n0M~r z$L-#>zxJoO0wB;p+Q%Wx^_@oqQ}?gxF5iau&-8ciWdTBoQD0?+J<8$NaVj83>)Z+$ z*1Th;@Mk<|qLfw?9|FD0I~YiaA^2FjUPfwg4-3i~Q;hB?4wo(hoW%ukTXlHJ=5`)A ziHUsyIUH!Td330r(4qo4O+>kBgMqtDo~DDGp3Z4n(l8a{4W2&AJ}T(L-bDj04!X>z z@5CEoSMlw3n%Vjs4T-7mTt|X5s8Fa(bO228L+tkbYiw6cOL;Ewoc2`hZdfre&y7?byv1R4HbQK+}s@eLlD_q z*Y!N0ZnkJUckoluRURK|JRbb`qG#dLru48Na$E+)YSCmurVi+DA5mRLh08_jgZUKJ zVmz&H!V37w|LAC-XDbUc1VcM(OEFl*GmqAFxJ~yo((Lrq`?xuWHB5 z9S+P9&){iG$-#D1M5Ya)oqb6x#pJ0{y*Hc)=;;lxC20Ip@CNk|Sn}y-M64pAo%Qes zWhA;9h^U#OXBiZN%J1m9j=XWXxRK0WKlWjT2GD%WB(%&kzR&Y^E{ih>+5)cg9MBag ztd0%fT9x>}CH#}p2B>3WE%u1KR8J_ zhuS|W;c9>Y?8>{R_RmIx1?3?4aene|VCaDsxNZPU0o~{-@DLmcCjpp++jWUdKtBNP zrwd3?hchr=2}1c;5#WgS>2$f563?#YLZouHmS># zD?#9S_+Vz_4GWF9e^Pc2fK;SnY~0_@{hhhLYWa7~{eK#im{D+3O9a7B&Yty+KMJ^8 z|7?X=*qYT}T37D`3mXz*zDJlWOJNR{(^CNS#^yzaTCChHX{XI~6IyC_EW@jkXw(VT zB($T(egGo38o-qGM!7%sXGW5J+_tX){8>;@O*~*J^Wf_WrWSJ+Z94+*fPIazZbG+^ z{3*#iz+Rb0U*&B;%zNExW+MS_#$A!xI|?*b#f=)DFlhj76#rbg$RB4s*bmtIer3~g zX(ZoyzZ?$}5d5KV<>eUb0Y5gMK8%C{yJlYRKGe=NR$h6H6E!Tzs-^0?EMXnKUC!n$ zeh9RJ*K7HX?cF2n=RU`_p65V;V6XUlz9(t7LB7sJ0YIaf@b&c)icQnzoJ$Eq!n}if z?jiYD=Vk+1Aj}aMJRSe86=zl7aVd6{cN^q%aA&<3aM;Rj76+H?h6v;8Fi6v~gV9vP&PS!ld_$2 z>@}axW7u}%{^6#DrfCHLv$zL8OJmVI<#z#$7k_8As?k$6Yz>I38iC#x^$yEE1wWCF zZ|I-_xJ(MJhsufw1CzwJC_cFk#AY`W(7k$ZC%|`5G8vL|k{tzVwz? zWLduA3VcayHK*t6=A3DBDnOtwu1j*(&(8VxQHbZ@4ACgX`Ch zqqIL9h=D9{2~EaYK>ljJ_jdJv<~E%La@7tOGnubVI^F#PRPULdjhl zf(oh#gt*e-GRK8ue`>!y=)2RaWFx`aBS=Dp&1-_Pf7!zWUBEi-e4!J z_py>l(_R{lbArfH%W`y0Kzsu}rUcX;aK9j(EX?K4+WZ4XF^6PwFrhr}A_WgtJgp2+Y zs9yj;eZLAHBk$}ufoJ~TqlBW1WjUrSVEw$Mf2jLzGe2RSts4vF0hYsL9r_{>eQ-Cs z3p%nr-!pGW%I*O^9)Q({x?)^@S6Y_5C({OC$rvnn)8$_4cy)(_(fWLlJ#XDL84&({ z)cd4msU77vrrCi2IRdvcg4B}-e*M5DKmP>h{mJ`|Sc!oQNKX7vM=W)wG+j#PEfluT z<27fD?PfMEd%6AmeI@{;oNMlfV179z$3FZgFtX1!_Ww$beryJr&M{kOYXPD)x6Yx} zMZ(0RK57Jt{VE7pzb+|A9i+FgkI;RBKT^UE%1LSR!C%Zd;8B}_?0l>Hj$iAM{v}aH zEPRYh@}CE8_}g(!o~DMSw~LBQ184YyN6F!1?WsEQ@fF{%#=*Q6?JzFeZlbe=D|hMY zma#<021!X~~AAH#VKYhSBp zja-~3sc%35rbIXv%`TQ9O^|nBsE`M2o3+y%rQsBQ`^xRx%_RoL1j1KXGG}{$!G8xY zcNNs$ui6UUWg%-hp8RSFDgoOLs`3Ln>zSjA;OtS=J3y?}h@RkRw;Yi95>Xp8|4Esl zN(R;P5<$^ES@Jo$#g&=nA4+Y7yVzztvoV{!Dr=m3r$6q|vP^k=UlK5;`gx(^A<{$2 zgn8o^%Be$sVB^K3I*Qev7de?GIajf`tRVI3lK;ET`*%`y2W&Bxi4Q2+f0igS4+Tb_ zyy<-nLJ{EiYxX0SjfK{GUy4r>80MH`3SzeZs0fK2RO)RwT%kf9R3TU3eZ5eCFF|xF z+0E5D4EDxYTY~$CJ^;CC`oN*<((&8vW!$+U`8u`ld`QtN8C&=J8Zh~h6A`G;<-x{w zWkAv7NMz-6%Me;ZOX=;#Dspkwm{a;Mj*&KjjrF-(Byrha=z#Iv`RRM9K=aaOJf2m@ zT6eUMM2AB9`U)eDoACBz@Z1 z|41LkbZaL$ps;Y(V{Az-a)i9NSORF&>miD9$#z?7zd`=IhTZIE7;L}sTj>o{1m1kpD%3gJjniaHeuX^!W~oz7bVdM*gu^~Sj1c2cN4QU7wWz;)0$+K2Q7i$UJq z)CtF1L768)+SyrW(hE%G{m>hh^LR*4VREi6Vo73F=R&Zex?-zm@MPf$J|f z3eR=zxWNZ_Vm|y!r+b$XrCI0~-4{Q!8^<@V_WY;$mG{T-Do(AEz1)%S?skG(x|PQtL}Eh7ZiMcLf@hl37AA8S&?^J z-qsD)g)KV+eWpE5KiG-R1NmCz91f1nQ!j$M)X7RJpaeJkjj^+ffo@LOp^lhClY>b7 z67HXSDA9U=5pTkEc>oa{x((W|C5@1o&bCcym#gf%lmNElxTuR*96d?Ce>^x*$NHar z>xy{=LP-$?Yb36na9C-8S3-LfMI&OcgeC zob)Eav~L3Y6*#G(vs35RP){xE_*_W8B#cQ1lTU9K)Xg}*8(X~H7yn`b2hcN0dAg`l zYk&5#%!c)2%>A!g59m;|6b;CB_5(Smv~d#yh&ypsa-G_g(tT#+-Y+-4`;7wnenTyM z)iZd^^Sf|tQ0sGmSvZGc8Cck}u#LXfXT_sGrJY0f7AWcE?$l39iN)=-_1A_BQkIt{ zLZ_}Z*&3F=!>E}PZk8?LoC_l#PGzFgiw6m*Wt_fV%b9qu!a8K>P(p#ahmW1QGy^tH zjyB2`&kUUk`6Q%A4igVQ6I$)K+$>{+$=&qcylVOsAA5JUeQwNih4%8}K&R`)?+@x2 zeH86-dAL)*felUv8~RRJqYa=Hi1D$h+pFu@G=CWGpuXVu_vfW@-a8pqXv(22+_LSE zOuhNf%OEmKwFuMxkG=Cel|p(m*$Gcb#a_qj3hgY?FY)|0_TD?H$?fSM4Iy+9LPr#k zj&!Ng5*0yIngvC=h=6pFUP45XUPJ|?+5iP9B1H%g6)6W06{MF42uKS(^xT~U&wI}I zz3aaBk9V!R*8Q>kLy|ntF0*H5&;HEJ9wyw+DYJ^RA!Ex04W~n~`_Xz6>zt9FWWt_f z2*MhgnxgVd=sxb9sCo&epNUiJ={!B6^3i7J*$y#u+6NOwJYW2Bdq^OP(t6K?ov`tN zdh^eTvptuN(R=MM%U0Pwb5-vItdC=D>cw_ped6N^lc6FRfiLUsUi|!G)8Bk)T#8}} z4Wki~o+I)yMbBV4oRuNEggW*zX;-<+!ib&8cbEoUn*g-4{lj*n7)WMSN_7eACj-1r&wy{rS@KV=FFC7KT;A|RW&~UuIQ7vhG?FNloMsvJha^b1V zSH15pm9nWr(=v<O)aYlMpoxz#$!7GrhssUk%1+^=4!Alv zv8!Fd#rZ$~a%bUlU61<#9Q}tP&R)B+d1+eca2eE;zTiT(%7tOY1!o3s{IPQT_v!{LrzK$Fa6gxu zRonLC3q8_PjA3<;)^bRJe%K>0w}iwlUM+8tI=O8-^BcZ}OZ;U~4Mz>3UfgT9PIot6}2x7kd*zpXaN>>XdsI zQMx0xX$H@yU!j!BHgoHx*yM~bM?B3=9QG(iZhtK5Fv6E``nK5B%o{vticyHipSDOe-r%kbdF|&mQXZ); zL0C2SG~|E79#$N_$damisMXy3(nDs3lwMUB>g#o*=IK6{?O${?_+J4uH|yrmI^L{9 zkMg<&)Fq~HZR*jZ9gzVc{wI3Vk%yK{=+Irq{d3*4iY_#lkH%=$d5#p+@8|?KcRSmA zL5b=52zlGH1LJ+T(hFTwJs#9EDO*ndJA_CLykGkTU|KAT7@H5(s+%}0Nbmuq5Ik{E zXG}HKNAHXATe5I#*>Cu7-F&jw_8zMXa2F@EsjAA4R z`uzOHG`kVw4H@r!?HzjF8|tRezI^=@u;cF03}qaiZXq6GLVXN%hF7j$&ABGj!q|D| zR=duiDiS#RkonM+Xd>pW9Fey?dp~v`pS{#eYJbe-5oYruEz&4N(G?=S8;6(5A3xxi zynHCszhX7;!B8+eQ5&yh4`5HUGkqsZa*^Ryqw>(oHB$fvl-`W{B(}7J3AB3lC4*#& z$orz19^iu6R;YDTNqA`tpM%~_@BG#xeT++EU?%Cf><0C|SGOrP1-<%LD=AiW2&wTs z-?^{*N9m5=?jy3$c0(cSWRH7w@830klv?4$lJm`{e0?TH%9N9=WjnRA;b@pvc#Z)gegZP9Otw z(mvDSv!pYV^3t-&Cy`tv2<9|Tt+UBsSAO#w&_AbipfhF4CkK_-Q}+sXE*XOU37h(; zJ^avb+d~*|+$pzH<^5icNPED!f@2mjWRROdv`oA> zJrf#GaKXc+mw^;<@&Z~GPt=dp6W7-Mof7LJ46T(=`B?isG9dfnjk+Y=ZVEKyP*b=}E zgy+$n%LFA&{H>PMCT`W9Xc1 za84rfP2D>h9x4N|d$;uEYOSo?+~d4ARC9f07aF{vqYRx~A2xoV+5v-p=o{t={cvC_ zdupzIdiYGI7=m=nhfPI=fN)^Hf}8oX<)D>*Hal(p(`D1rXm_JdlRB{A+f1EKAIoTp z_cN~k8b$>Ql!O;jd+0oQy0g&GS#HRwDe5_KH_jG>#AGmZz6nGrE_|(T(a2AmnmG>0 zsFm%#lPG7n9U{6=meGA-g+4}Pp73fWUUZ5_@E+EIX6w76@ImSMag0eCZJolxY8dgi zG8NQ-0VC7fC0n~G8nmwZ<95DLmu%<{z6$kwOi61O*ptPly0XMYK#)NnYo(IYPP~rg z*G6s>pKJN5^eps>hJO-!K_KIn@rVOku8JA8K0D9JZ1tJC#I=|8_Z#!xUY)xHJOo9z z>_X<2FK*svMH5K!N5t7H%!aQUL}ERlbmvDmMvB%>gGlU9tTb%u-FG_i9ZB162=x;7_7c|l z?Kv$?&%wJVY}kO`M$A^}!6*6wVZ4^gf@|coz&LRT9GHF4>J8()k;8_VYlP$5$=F2_ zn3%R+e+!y>eI9?h(-Uf4KA^{JU~?L+n$u-uv}HK*;4sa_02%O{v#m;^47z%}WbW8b z@!c@m3S&*y2&84@1aGAJqaqc86wFi>#8cn8#;z8FUGT@FBm1=G4I=x_WCPD{w#q`c z^2Rjq;fhT?)V_!90}|e)>v}?uHSg>`gdhk{zb|58BSC+r0($wy4ODVo@MgO~kn~g5 zo(Q19bbAbz>7nAkw)>9GXX^~c&I5xVISu8`g~|;?aZi+ltV_XENS!Z;C17Qt6XPg? zQRl0BWskCb4uS1i*5eY}R&u?y=oh0-?QL~Y5IrabyfqE9_`2_P^yp$ywMe-J%(rDJjo;sccvwCa zX&F%QrKL!2E1qKPq)E+a;xJiht*JBqLh+_Fg>SJ`1o!hj z|G7kHHNp|8=|n!69cOaVAZ3^R|{2xk$y*#(QJh#0(x4k^Ky*#(Q zJh#0(x4k^Ky*#(QJh#0(x4k^Ky*#(QJh#0(x4k^Ky*#(QJh#0(x4k^Ky*#(QJU8GI z-pg|XanO57e0v#+d+C?|f6p!5%X8bybKA>v+skv?%X8bybKA>v+skv?%X8bybKA>v z+skv?%X8bybKA>v+skv?%X1^$xUrY#wwLF&m*=*Z=eC#UwwLF&mqqmdZ7S9OQ=XeL z1VZ(ZREur`0;N|o4!Z$0R-=!HK&5h*?h~O)IY*))P-ys8G$>7X4@xTbl8+`*{U{1c zr7G+Kf4zp@hd{zZiBy%c_aRhrRiQ-4y>JN4ku4%I0U8ORfhH1(o09h+upA<>5*iJG zL92+wmvfWTO<4cmq?!7 z1JdlmA&|R(0#$M(3RD9Mgn>VxuK{(sNpvde27kQ*w1YzLZ2~%OhJm)Ak}nqleHUL! z09wfLQIDl^d&H5*+r9q z?!Lob8#AwK@>i{gE8KjuE|d}ah=sMeH@!2|1840)n)qIt;{Ty(ihJqXd(004Z}y0= zM}$39uqOrol!D???2ra=QXE7lf@>?2u(b;vCUDhAwIG@O=7eBvA4eVS$J688+rs-J z)zn5pBZ5M*B3907XHA>YFCX!^v~TOJB`wNeD@eco5j+{(VDx@SRjANv^GUlkMn6b~ zpLk2B(3xU-R5M6GW^_kL40!vnhfU1lTEu8qu$Fpld(w8=shKtHx~t%l)Htkn;!Q59 zqqQ6h_sZ~fJk|(qPuqG2VQu!rd$dgCmqoH6U%y@FJ32PsFp@H>ZgN(;OLzQqf+BI!qCK`WhD7i}|L7dH7 zw0;68lwrwq5dOTbv8pmKC@bC!ESWqVdh7ZfU%m8F*+m8N(-&EdA$g&NXTkV5n+RjW z+9OtQ4Z{uh!8hS(aR2mkq!sggWP~(*`k{p{5iF-H_-sk*0}3&6Kxk>ZG)aAoF%S<4 z&mCHzhCAr2O@|0xA@PPG?)#c{G?l|CjPS@T{PHQ%m&okmP$<&YxD77&wG5d#KM_a} z;PD)ez{gkGf^`7P#l_up2WiDSUml^VJ5~cLjHIkW6qFGn1?@|Rpyz1ZxS;+YiC^ha zKk*^H5U@lLs2QuDbA7(h5UZyZrJ3@>*^pvpr8E#dT82HYu(5ecQ)Hwy!d#a;3L8x{ z=01`OYQ?_rIKrJWG8-x?noydA0yisJm7 z>K!QG6-S0bv10Q4V#w@3w8lNGu<+swL>+->PoDqRVr0li?56Nrp82;CEIO2aGeW8R zym@LtLCrvP#|fOCaERuJ%AhlZ#AxqmWFwZX;R`Hq|3^qJdQ!KOSCLfN)4x^yNCgN5 zVIlG(-<&-y2tp9jLK>lioRPJsz*Wj-`zlpFk~nD&@M=}sEow2OAk1g$B{6bgyIF@! zb}4lGXU9_3gv+#;=lO|z(o~>D3CQg_CWzqISZt)icg#CtGd!0azOI#ItQ7vbkw-%Thk%mv@c>w_2c z4bxsQu=bn~9PUODBQ$@b&eeZ_b5Ybr9`;emh?MY-%7{1ovL1;v@=V|rvvbdnvkJ08 zE28{v>13`|r&^OKiL|jtWBhST-6Kg-6*(l4zQ_ewR+Fj<+_WZ&4!$-`r?+*Yo)^Aj z!~`R=^gf}4EdF_MxLo|&s-Tu5sag~@liC%2o-3pq(}X-q9hHgSiC*wVmhWM@q)CKg z8=%~uKlxw6X6acJFC5_)1()rm0iJXH5)E$E@39ml)~PFw@WNSQo9cGVNhA$L%AlQ} zjb1)={VATLJY>yOaL<1S>A|l*qe~3{Nh5oE`+) zC_V{P!~MHOpuCA#Lh?F7Ya|t}>Lhb)ozy%8%?zuB$7>-*;)P@faQ-Zy*h%(l(Eg>6 z40gd{G*)Z0#5&IJc6}|MCjKy)u3xK=A-8d7g?7|N2H%KFfm_};;Kq7tcVOvi16)Jv1 zCqbVQpnc=?i(#N=Pr^S^lfYn5^BLb7<%4_Hu;>J%U- z$BhiuNmE<`&-w-7#qp5!k`M~lA#Loa?uEu$YMu#29tYfZC*_F{5*L8RbP^qeckmI< zi%^}a+(l^w40053LyMysxJgipVa#${fPJAL!hFWbz8w_F zA)S=d7%h0(0WTB@753hiF(MI+0rVnTTyQ27`xwd+;jmQL;M;Rc#)w>}h6>t3pUx*W z(dI=|7^$*Xc_#~|A6(j4(|Sw^2qAJu?WxA&V+Y??#Zf=OHHF|>qkT>*U-l( zFwh>-Y`IwxpfX!A*0W?>KxgavUE*1;8>K=d@~~df$NeZ-Y1d1yJ1?aIl+;;taMi)G zn(-Sx9IMWH7~d<&trOW;x}A1N=^J@ER^~KxTfwJn053qvuFq! zpE*f%L}HGPz~P(6cfouW;E98CpnW59?X-FO+{tCF007u3=@BZzq0LegAqu((+9O!mZN9`*I%bIqgVb;vHn z4nEuT)!D*Ffa(>`e4((bT=QI^j~(>fU{eeSIV_21boft?Mb_otgMz`5Xmo(9se7tJ zXmJO!sds&0XNtTB7jkiJ*29t|v ziig}E9r*<05W66rIe|b=de8)@f=I*kcMJs#n)0>Uazgl_T{9PKNRsm}9k(RmMZ9m> zUD*ZY(?jE_4t_ChA=T)RKY%jTF&)j=Vv&5~N+E~MeeAb|wtS&XcF*}Zx?oWAPaRqa zN%>;Y{1Dt&-TzrtE~@fV99AZb@K54~Rj1!y5_ z)e)Zp?#qYl(t{0pU*~areC3gm0{!dK`ru|zKPIFDyIW-vYo+b=^<+3gCDKJypF$1t zULLW~8PVc{`b!u_-^m6{3DF{JP|IcD2!DuEJ;8PBogUdH0Mtu@uU)ymF&W_{o>Zd4 z&}xCzg51#l5UwhTzOP%3kMmeWymCqc#uRDD#gW)o3v0*VW-yW*hK2@0l;j!CPg)5U z)vw1!U}lKfbIOoivUvdsI2z)j!#|1<9jPKIsYfsjO*R|G4KT=9+bt`O#H^=(Gf1l$ z?w3MkRzzjcmoOz2s0^^&-f3#wN~^{i*o78FHO04;{8^3wRzIVCi?(p<8QbE9)7i>^ z?YeI0XxGrEb5REy)JN9r>rwkNIcMlZV>g{%-Fw zIYtZ=tECh+1BQf}WO#sS{q_d$+e9`;V7O$*`X`)1K#iiF*2<2|hlIPl_#;h&Ft>y8 zTNmoteFGb)0eg!9X{CMsLmr(0Tbxy?V_z5+T8#xXqVQ(`cavA5p6;0E_x^xJu%>qO z5|Egu1@S{Pt1O{)k!$xNg3zUZ9tKKAFh15WUQKeO4BkyLs(DUho12iuPk_ElNUo?+ z8d)yO3jU*#SbMJE+7{;Rgk*=-oZ`Vg3G65A>pSJ*#qY7r3m1oe6U)x-vaubOIAIn_|gNpwxXMY2PlOl{#3W*05dq{$R7S z2M40ZoTb{J9>;);X>)ZSf62t9H@k`hum>7h%Pd#~^P8Y;_XlMOTp8DRu&y6_W3XaS zLTuwO$ImeoyCBQ1H38#dRnzS953Z-?y3!4brbW@Q#r0&-Q+OI!xt?fEhvs;?t#fzzsJSM7*cbteK&!31 zWdGs$Wyj?rp)FbFr}+lEB*gmCEWT;+|NN_4_{;j-$sM4a%&5d=TB6Ldz-AR9Q@f3a z&%IpGVjm`#?gO2Kvo#sXZ~lyE5eibr8voUoQ3jT%Ah#Pw%dr;gxY-NWKI4VFZt#aJ zKJX^HB1Pf-QGTQ3eHN_+hCV~p)Byv(aAzQ5*53SUjr@SwTNoF!mb*~Z$&UUFBpg$s*5BN>7Khd9J~-4620_08%u&9& zAdLnPzz&g#@o$#9L1P%-TFdEaTFQGS21yNw%Y5z*#}h8UGuH_S;l*v@uJHpxjBQ>)*5{gi7(Bjm_bd4)oTqgi^$TO_G;~}L&A>d6U{g{9Y|G|ac2m)MI> zZXkQ2vXNPJyq@0m-QBh0?HZ5Ij;3j=)WA0|&(OuZCg{uM-2qADFQNaDN zXHQ@ER_jul?m>3X&Z}yfO!Q3PG_;r#3XB(X6~8Yhfz2|1=i9G$e9J(1q8hCQJe$PY z15=PJ(Bwuz1nS79$|<3 zj}XcD&JvrNN_f(}Q1esi2RyIVy9&Q`#9fhRl`iFO%I}=xUO@N8cS0Dz<oZBB7k=_Kx^?xsM!F-|Hjs3jx70FKSl)`%6Og0>Ox+@kFn zCiE+Sk`b1s9#s^54jj#|n<1SK(9fX$FR_C{bERBnJRa6)WbwK4g2RjJ+4QQDtB)vRUrb>fuJoP{> zrkH4CTfWh<5XEzA=Mga$E*HI4K|UMgh@IbEFv1k`5gz= zVgxo6Y8Xme|KoNN*c1z_xD)Zj3RcHcIKITDd||5tiG-Jx(q!o67=Yra0oF4aabNe~ z0o%i0qp+6}PtRq!yNVw6XXOE>3L8h;*$R<_WRY=eQxB?qJpxsReFyFc;4l{MSF6N+C46K1`c00WhaRX{XVG#V)u9rbdfl8 znCRZEGQ4#?^bcMLh59Q#WzqAaa*(grPVil9uU;G0rZnjdn`0f+78i2wQK=`c^o?yi z?WtJQ{Z3Zfj#mVS6;trs%OBgam$m1A^c022jZ9K-;dh4<+*#Jz;nvX-+r-+{l-lCh zK?^R@7E_#xEoxx5D&~DSKqng&Jmo92KbmYV0Yu7wWmrzvg2!Z08ELNRRY$^}Ly=+7 zTs_0#xcO^s*bC>+(eAjh7gLBiE>oEm9HN&A!5v@{qU3>-B&d(s$ z*T2(U9E$fgA}z@$hOtO;ipRx4!0)4G2xrV4(vHTy=9{=!AymGURpGkf zU9G;Fin%$KN14I0WP-r&jr52h1CMi9o77<3+a3o)3VpE=Q2+O+#K=<|orwb+Q@^18rYQsER}{fZR3 z7d%pB*t$`#TcpoF-Ty1N#Cj}NM25^I&#(dwt5z3zXR25*GON+%MV0QoqzdC?6z!JF z{6>VOb@Ep0Se# zjV2vx6kk`4H^XyNbeSvtLH>RV){-5B=`dk-VW>yItu!{cS&T9{dUVjRuhquv+Fj5n zuCxSkP@(=yjsa8@hH)>y`%ERAZ7tt%ZRn(O;h;Xln1cdBV?FbF8hx zB)6_wxNU;FU5#yg9jt*=u+pApM@eP=V{gR=(GnI-r`|)28|0Z~!_m1q{Mxd`c)L{1 z$J_YWnfUJ{wNnZuM6?#h0ff05H>yC)Rj$5&CWb^rjn~C&|O4|pj84aYpFA%pHAa*KDE#Hyk~UvSW0TiI=U+4SN2C?$X$p?e!XMYd;v#>Jhs>|m zhP%E4PxM?BMChp+ZCz~IRBVbj4#0I)-vP9p^AnCdCJ8(83;bs~uKpjt^QA>WM!d^4BLh>AL(L zlF9-*{}~*&osvJx!A9gZPmu7qMiG>Bst2Aw%ycz(ZOA^^*!2*dioj~%lp_gl1`R(i z!iwwXl{|s4%XkdaS|n&@n_g2Te2S(>AWW`4X!uRmU_VKi!DLSWGq7ynU~$&yQ_a=j z&J%&*kLdRa784+@Hy>bypzA~Uc-{1OO@DQzVvt`}P6~a=+B&_D#FjAnhumt%o&*`x zySAvl`3$OBMyD@cpbA>{3`VT-&!U7M*nKVgm#6 z$qLlp8)LAoHLbNb3_JSR0GQfKEos`*45TTa!;LFs2FdwZK}E-1=P@axyW18Ra3;;7 z^3pmyE`Hp7zAa_Kj(|CPB@AhX4*p9eR~gF0uvJ&mWyyWD&o!RUBBMtn5z427(8Bp$a#pL|{eT%)Fy zmcZ+Z%#Ok?e4Dn1zwTNB0^t>ed{lELmo$B3HUhobkRTW7aZVW#)?#O6G~=GEat#U_ zmzXCgNXT6T&$kzCqmRuMH|UT#gM_sY1u^@$fakeYFvI$>fM1vx2Bf+c;VMgk=)YQa zP>xTvuxH54ha#QOn|fixQO2;8tX0iXYqw?(^)B?=G0bA5nHr$!8PY_s1R^9po53SJfb4sp7 zVEqp{|0*d(yz3+mJ^+=S&N7aPp9>N>L1m*&>u=U)B==t6N(Py=S$Rxx4Bxht;c@TB z?dG4RwDaw&?sp{pW=|7xdX~;;?Bs%;qFFrZ$^?!l?J=|h zm#a)>FR`!HOs*61R|@TRrwvD!+Pw@Go+}07=wyX!Nc_nyG}IrI!zO~vc1EAPwrm|^ zjLy9OVd0K zR-(nr_hs1rk>9y`W5;**J61<)LF>|`@EZ4`-o7UG#B@k>sF|oE?rzy<9fud-0$A6fD0hl#x(kzyv{)+B9DeMST8?cK3KsaJ zIeX0INZ@6NlJcP>L$Tqk$?z3j*Lh5Y2q8Wf4o@BZ5j(2 z`N8+jvR>dlRna5^3E~ER4E45ykWs(16owM|*i3vkVX{i0)_vsA@tsED!rq0@q9l%{ zd`RZe=2lSyP#KA4f0T*6{r5u-xvulkr2`twQ505L`z7d+uHqWjUVo6^=CT+NG$ycy zBAC2${kZosGpBD+792*)zBcXHyU_QJIa9l5=;=>4*Z_5Q+ADsS>`k2(f%vBCq^miS z^E;EQTc>M|>TA8yIrYfK zQ>%tMxf=JZV><=d0ST+TB)rHxQyhWiEID60jF+2kGe(7pYn5fV3?<1~*>(YWl{z1H?koC+GKC9)6Wcdi@PJ@aXo z<&&Yr!@9FPZdJ4_ledbLfqUpR6GrRk{D^`luV}Fgak0jUCXO)m8C?jcya$d1DBj@h?TGE)uVa8EPIO7ldG|dDfcwiz z{q_w39!g%e^#*?_sToIG;Wyd<05)C z#7%F0XaGNgn2I8vb;rAeNP`v}OL@u#qg_FS`Ot55uQhlMz1yeF<~9C$?oePc2rl5@ zR8zdV^h{nRj8LEc-umP>YwB3a2G+|sBOu%o|JVzoDicQc#V1~=cmz0ajMHy-G4Bi% z=&9Cyu;e@S!G)}5oJ2kit!~(KcrA|Gf+gNtA9e!l~4SNOZ9vPRZ zVd}84E7&(g=hJ!G_--ga^Jfu@|CXhWKYYMal1ep3Li8BkXA$v;NhmIZAn%}9wIz|# zuNQPvMffP|F40}kNzXp9=p8%?2-p7@J7Jg4E?hX&)ul3}3+O(0ZsiPh@I{x|F_h>> z6V;vgZ)r(HlV)#S@@Q{}dxXSH5^kuzUf(Yf|3l#D@K0V|DySVJ=9v1DcCix=Mq~VD zmV)p8;9ZiJayI27hxvZ0c^sfEN}J7ewE0YO zBu!D47hyZ1Y}@V^^vjntmj>qaGx&IR_+jZivs8IYxl4$|CE+;yfGy*v_I#x|R?p8I z4<7CME950Xh(>O?=A3~cH`O%l;tFg7F&-x8>M zH;Je#ewt*pgUw|GIvlly5)+C)2F9Dt^E+e6C+cm!BnZ#AP`6#1H4qYYbRW7dvWZU_ z#N<&F;YkLJ@_~?_#<9*7dDsh4`&R#ME_e*M^=5O$AYL+Y$gA4wjG_(c;RA^OZ#mHD5dh1$iqSBhSQxo(s z^ETzg1H>4hB8|Y&wtg6)ESAu?h#0zJ_}vMK)wiNf`f9`I@0E-xNq=d8b{2Z0p*J?M z@Hb_ZVRIZAwc^*Z4rEMw9Y)7DTwV7uVM0&SSWtO@D2FcC;&+rd(G|ZR1^*3b6c~3M zRQ?2;!MN=F4i9c=T)QbU9`BP#tV>(td0~A8NtG=>x$lYBE93s86oHAwe%D+H4T>yf zv!jP&JgvSrU&&Q(-MTJE>VyMQm^*JkKehuv+SS4YftsaX5XD5Z>|a}BX@PgO(>o2M}c@X+nbTT z5Af>>U(9tZx|{u&y_kSQ-v!;#dw~$KpuXOD@z$yh&iQFC)!F(TgS2Zc#b!qQP zMo+HYT16x{ZwDge11MypL2nB+qoUQE7@K`kMrZ9>iG4pSnf)^W6}oJ1Z_Ns;^9N3! zB!->-+Kiem6#*e(r0xW17LSBFJa;t3a@MsOw)sWAhuUaPKMveTK0RkBBi`fWmKE!n z#rAU?l`#hirzmnU2@m90`ek{3jSmA8nTvtfLVdF~1!LWvNfevy_*A-{wR9+uXA;)9 z)|gnDL}V~be@NC@$VTYph92U((S-`lb|Q z8U3zRXir0#S12+Nnyhn%m|yemItMgvTVt4c>sJN-5H6c%=d#fLxwNP6zgfe1Gk6wW zjvr0>J6c2moz`%3LT^lqN_rMJ&QjuHnksHyQRe`mI`@-m-I^Tg35Gk}Xr;wjCSewF zV(htaCCZ|qT^TGm%(n;2%in!VfU3qc7zws7-xJ%OV8is6rNEQ;fr+N}nZPxJxE+JUANQZMo;y!bEfDlC*pNVeo$Gsp z0iFQ>iEen;>~m*{9H&&j9nAf*BQ z7R?_9z{b#IDvp*IcsmZ!%Ku0;KkQBu$~iBF`Td*yOK{w)Ebg#?{)2Qk&i$dVGFr8v zTXVeRov=ri&-ujWE5L1Jr(e<0UYq!rR=>WU*x~uh@b1K2+n&DlieS9=j!(mA+Q`?r zL;4dnAEXZfXUc7*Ugwi-1mbXBovo_I2xcLQ`_qkFy=#%Nhn54vwalpR&?DnK_hMlgdS>3q;n`{1{VjYw3rmr}@!@ zei73QpH9ZSIfgoJ{<|rBS5oBjuuF8VmZL3IM;D?Fg!G#eweoJqN)ibWvbOx4v!xmx>0@riL?JUeu?^BzWyJvpN3x6 zxl`n#)f~52t*2)<8Wv=-X4)Gcsy?KL#D80+Z7HUbRzvP zdwn+$rIWKM5Q3?3V+^onZ$cO!GP2tNEWeaBdhln)CeOHt?)sGeb;&=3#i~K$VjDcI zULfnf|Qo z1*p5@k+hH`6Ud~^93UN{S+9{bK7%MPu5d!mEh$BeVhK<|g_+cJ0WFf|2GoMsQ;umB zK;(0K2mGXEiNihiGkQf4`-%QDhAR|P2*7%wSaadVe6a)v7RjNf_m8h?>e7U+x9Bw@!GboW*C2TN;@VoB%J^}z zml6OV{eT3nt?}eJo+iz)Sfyj~RonIN?n2%5Ux)kg^RW~^4A{<#vDJ2M&fHf0Q70J& zZsdiRnOh^NvUd*dOZ^~o-^HSzi6Ka>>#z255P`WnXb{t+uets0&E)!OAkr)N5^*$>wo*9J?fpCYXBQ6 zZ64*PKn>uX9!sF&e3ME9dR5hz@7I_7(3>)%vJshN92|IkBCBe^47a$pV5fl?qa;Ux zX`p3bi~NwwE(@642DpbnsAsw!{uGtX?Zt2E%V#wXtmGUDmdJb`(BYur=jegX}t5{$S+=NHWQ<&LAzAM z`hX0Q^5>>LX`?MFxn-u{)78|-MgP3MN2-=NpPlV~VIpHG(c++0n`3AU9oDfkpo zw;?COKn&Nuk5_BHVYX2CDYNB_(7GLh7Ezb%83Z9mV)KbVR4P`Yp#GVDOPu}1{$Zdu zX2Ub+hZb)yKs-N-aX?IA?~d&XNLi@etBnqP`m>#b9PNO|)STFvH|*aam<95)?(F-s zy(WS1g*=4!oTMpgf0P3$&){7otz{uNduy8^Xn6l{p_mlz%IlrZW4U<g4Ka&0am#<&VwWFX zUiLEfPw%kvxKjA=36o2L2nOs?Meua0!DOGu*4wU(?t&|uGksNF*&y7V^tesmjbtHB z$nISLP=D!_-%c#Hi@h4TDO((K+7O!1L4)!62r0DM@NUcShC;;{QwyIlwe z1lt?nKJKpDA}WcrZgx;Z4!*Pm=>{bQ;wQe9~$%4ulrK3$t+@T?hZrIX#CWULsgx5(qFj6a zXda|ZQB5r?z4q1jbD6k-XN9PRAx=nClBxSwIj2F%wKKO--h&*=y8-^JDff{47lMPi zgj44P0b(%tzF*#pCY0GsY&+ZfdsW9o5z%{lW%uFMv)GbLgX_25OuFm*@Qjep`uVDp z$?xVk{1RVJ|5*ncD`?!CsV})+(N#O20i-Qqc(vxZDpi37jnxYfzPIa#NJ$R0%dc8` z1u_1wrt%=hzKpe3D+2%C?Qe)C5H7#%WCxtVwQn;qB%edDy(*v5-nk+-R1HOX zexG9A5PhRT!`(e=h5yr{8G~B+YmJOAGE*DecuSH^ zQ}2ljRN4PQ5+ethmoE0s|+ zh4egdcwlq{(wR~2{^Nfb8cSa0@nMF%^Z(Ex4>>(tE_HeJcI~(Dp`PA7p}B}x2Uh?R zMKSm#9?^Yu_>U5_Dum=!v(H=rq)!Inof0xv>$JSC`tC2x>#rgC?8rK}lx@E8QB}w9 z_-OC#Xu{m}5)x6iA9Q8h$^WMm7QhNKe>mo-@_mZ(20utbVh*U0+ULXi4|(u7v+{E_K#QC!dKxu7cOKNbS)4*!iW z!7TqY6^tK|xmjF}^)6$d#GvXfWZ)s;yV{q7+at(4{q`%`L&LJ4f2{rmJUW3PyNJ@? z>MROqa1PtfzoUjJ2L(rSpnhl96aNlNrF<_Ijt_6`C6KG5u) zT>hLm_Sj?ZVEeah{O_H{OJ3;~Iv+{fdC*&3cbHFs0z)BfqVLLNoA%Yr_YCBtGv%lTzBKJVCtMb6&;=xM_nek-n8mG)#QLM9#`p%0a#zs zQvk+U??PZ8#|PkK8SVMgK1BpdYT)nsE~vy2ZX)8?VSr? zkixoM4`@g}Bej8?Z=gwmP4V8u6AVXyW+_|a_<~Ir; z#iH#ZS<7dy`u6SrH^QXmXZk#K$OZtLHOKmo+R=o{_=~NNaVE6}XzS;nZ+DQ5-F5Ni z$pMJQcYp1re?njYrJML4XChZb!rs6I48u$A%M>UFX$E|f4_7S8m1#olKOeBRrpjJm z=+g~gg!Ch&Dw}O-`oDARu3fe-`coiXp5{)=5kk=>#i_vOX!xjbPUL?CRY|0W%ANmz zgU>Sk50F}oQSKbaK;^$-OM%M(wgg}taRj(zIKUxzZs6)1B&TuzjjEEty#JU4==eYI zV-jA9jlTF#ToAyUzSHY85GS1Fk9Wq1bTg zq>%aPq%R>G%@g>CXI7RczR~X@Y8z11XpYswEZuHi;4pOA;Dp>zd8%*_AlQi9#%`nI zw7YN}x|u0mejR=e1=*=o+dekn1Y?9~c(#t*FZyzgql5o0cfM15*352@iV7@_jO!ai zQhJxAK(i<8)U2Z_eL_Ax>5Kr}`RezB-AgsT=7l0&cV?tGKie$oG&qib3CMLOy31V- zWm0E~h%E#znaO|+=G{1A2@zb%Yt}K9wDnsq6D-mF_0HakG@+&rH_9-zm3`yoRwlT5 z&Weg%PZ99iSOva^X3tqAT;?*rA@+Z@_a**Nf8YOO4B4_x*6izBgeXLqN|x-A2wAf4 zvhSu%Q0@#zzryl-cY(D$(KRJ?Bc%;RwflZV7*5|DMPS=1e z)ve_>F|u28xh8s%bV2;lQ0#)3UvP_z`-sIkrH0PVSO1mdG!#&$z@z6q-CkaRc1X~j zuffGa5xTF9r8yS+C7kO=(yXd#tj`V|#yLUq>=;ci)v{Q0S|%tw0hH^%zfvE<=zisC zL+`Vy)C001&?hNcudGddID2XD>_2%RhV0ud#=WZt2g$XTk_e_T5_B8UEKAZ3dlkO& zw7P(AW{q=sdXgA~oqTGl5NV;SKxg*>^c! zMNW-=K3L#hDg#SU)lw?pTF;|#t%bhYLhdh6Qy@PVXPq`gPf2apyz zTn@Ar@`HU>>$=j?{ZHY>ziwT@<%r$R|WY;4K+wiA7o94Kyy)h z;Hm{BAh`S?mA?OGP1<4CTU?zo|L*p;=n7=UGc7tw>f|w9vr2YcZ&dhyc+^XtTbcA4pBM) zz+rsu%p!E^5*+}m`9=h&yr10Qr9Dbr!T7Yh_QgOdKH#88Nbd@^(qzxokI@(mqzyEb z9TekUZ?lZzUKUeOEX}|FZ$h=5o`;>QA?nJW>+(Kf+fUYI%<6E zl{fTXMJXlnc*EdUWEFESTJmfRXG*Qt&&c;P+e{-nS}_ZV9dN)4f5F{?c&3f0V7h9t z5!-x`@=poc@|oJt>Ht&tc0cDfg}B^QZp^4Y#Z+~$2ODJf`G)(n3jlIXG2L;f+z@!> z_C^$0eo0QzOX3NmUwybI<8m?=`N#~B{lVE4W+rPMU;Kh$h=T^QT*F2yZXmx*lO3c( zKjYaNRBFkYef;KM>Hm}24}gYjfGXQr#EFR6azZM;b;gXsR_aga9w^~a-C0x3Z`waH zc37rEGbe&yCgdu;*uxJ$;oei?m4w9pw0g~fX*sio>EGZQ+vy8B(4$~TH0ONM^Y_fP zu=N*tH@=5ng>c3-T-V|+>w6vFPd}^@`MDNKI&4ewssq{UjCV7H!`ki6&XBJ|4IZebsz@d3#b1~>Rc!qQQ0Eg&tbu_m_Pjsa?jV%dD)4VyU|?2@^R%!}AmCjGBS7u* zA=z$L6zJSP!RF6y*{b|GtgI7|Z9wqR|7L@vDFDh1X2iUI4Qe%V>M|LtAU@XeKUQ-; z$JuW>uj<<#x>i$AriXv0P9+vLA@YQQ*R>bIDN0EdZJd!k{mVV^qj(mmu#y0!NQW$d zcBW{(F#m18KJn|v`Den(g20~M$9a}}dQ-YM8}PA|)cw&{SrLSj8HR|Hx=VOMEQV2~ zQPLWq?8?bAgJkr0EQCmn4!w4MTG^*0q`dv4Qn`oe(1Q~M&^qd+Pr^eDKc2my6$T_# zE1CCVgtN^D&&W3k-f9V7fC|42Pv01 zc6cL>*8lm=$MZwA>JNkHVZqlwl8+S$e#=LTltGzYkjzQ-QN*l(BeP(5MeFJ48=yZ`~4{Z*0cuY zr)58Won`7Y1!H>-e;Z@>E`X^sEYu3w$g{BO`xbZnU{P#Mkw-MVN7t23Z1Jgt?eO(i zG|vGBb5bm%KRO*p+$Ei&%2RW4-WED2FN&4In>#x!Q8k2pfXd28_9br)jX6cPa^<1Rc*(!NPOLtU9aQY8SwFx|L_WJq^uAwY=?+S?uXaWWicvC9 z3_WCpSfQn;xb8;=X)H>LX8Ut;$IXM(fVZ)d*)|{hYi;PqXN5a-!!p+~AJD2Uhh-<= zG_iF-c347B>WwsJAI1t;->}(LNXAgCv|?VrbZtYm7OM{%R=ca=;3d>-o4?On_HPDK z-FQ86)Kf8nf}axj(N>PIBcDEPu?WPJc0cfZ1jk4(Y|Tz%1gAl|#-EiRUuOHem)*BFH>3an{7y$U#hD(Kf-(=JVP!s= z8SK|yQk>Qd*}^0f&ZCCBfMS~8;u3y)E#W&#EsZ_c$-{S`+w#YO_yHtje3$mP>srqy zVdyRVANQ}z2KW;ybu1`>3C25hLfq^e%oL~UT2xz9h2>zKTygQfa$IL_exlNPDrPV| z0e$d^O3})m^%AR*5sT3=jmKA6s2s<(G6?d4{8{@A>Rv%(O|>sZ$HF@N7TyIcz6#B= zxf*o!T`fD8lmLE-KxK5D`xzy=u>PITt6mD9m1b`)yQN_n2Sxg zW`_x3tAam1-8v_@wlHYk{xw$|PZJ#?5ZC0-jZk#nHPe~BH=?I+cKA9fs(ruvgIhL_ zon|O`_tn=dWcOe^6ta6EUH2FQ^;pFN1X<3Oiu!on-z{ocbANa9z^4I6`=Q50a`VMz zyfm+G!1%IK4CZfKm&s}M$V zES*J85WHR@GVD?0;~-_|7WyiCDF4#8d=>`IlTjWGL!ORat$XWtptS~U)O zvvg7TJi0X78SlZ1{;)QtUSH3Sh;e?5Bhvvn^zJnE#_ui*3ib7Xz&<^NQ~i*P(xCJr zv$p25sIZ{3U+(+8bf@shuPd{oEIhEGBgY1mTzds4H)fnd+nuilIR+ypCMQK9!yPOT z_S+W7?U<$d9~^ut;zm)DbT(qAe_x$&;{TD30v)&UuALW3@=sq1=tuWWqg_Xz7161sa*M%CQ{%K|ys zxmWG3u5d@%Jgh>Hmt{aMq_Il4Ui%Q6(+FB_Ft7zq-x?Jq=5+zo0tHtJUPvX z`z@Xk*c}tLI^E~{n4&Bs>d>!<6kkd^hm(L@52ErI3JMz46O{*~qzju8-+t<_iSBUz z;dMLU*{2Q^d`Q&jU>_vwh}3e}9@E`C@B3BWo;O)tbaB2ee&by)I$C{z+IgEsH0svf z&dtxOu$jg65nA5hp6nyf^+?3L&R*>Kc#yqP=KFjhwwy4Mr(Px8#f5C>4&VD~HARnF z;6C?OUN=_bV42eZaLva-&AFQMo^4~bh4-$!dP=)V<$Jn_UHxh;-H*bL&0(f1KfY8O z^w!mAoz+bxGwyUP_PGw>P0PC->reRCWdeOt}_s+{_B7-lB(RuL_Siy>66C_0HeC z^cqTcz`0_g75ZZSq4uwJ^t=4iM1Ay|sskpv9V3R)sZWCE&v%Zpu?1N)?cHOGd!H^Y z=+mnAt35g6vFm}Q1bY%z3cy)53~I^8=68dl)V@4MOu^RmHKmujBknYOZX3%~&vw4tdDLQ%Bs z5yE6bJ*$NR1!w`A_Q5dK(nNyUa!L2uivee_<2^^jT2ko?z{R}_g0LuTZe1eEvf`5K$66gtUY{Y$?oxXv$68#e#3^|*|+C0 z-DxKe9yUe})&xg2cSLD9cQw9gO6#Bcc6?G4X-PW`sbh=bctq0}lsHZuWQZ=KT>83D zC@Op)Z86Ytoeq^X*+nyxNSl}UUWWNo!~`UG^)8FQ+}OxzQMJ^ZHl7a<#KwruOdllz zK@bI}f(O~q)Fpw^Pg`*u%dEpvWlluE74^fviVa9oXhBxp0`*V+P_Es*Xp-GPD{~cX zhU3?p##_F~IRw=fH5y_jJg!)=;oftpo_C(Y@UL7>gM|uK=vOrgj*ph_ol+|1&`lFL zaLCJOd2;T0tgN-7YrfYm3|n3O)Ri;|@_b}3A>-uc6T}sA?HR@1 z`BQKZEE_Vt!Rf`@h+L-ipLhAjBe{6wnj6;+@oupy7i?9^ZK#W!^6D3oG1gF+nLfHh zvete&{k%1^Q~6a(q1b~H`JA`0Y7zxCTpm+vZTM(Gsc^T9oaoVlFBVLWmjkp1LQrpy z7NRw0HbZ8@gC~+SXReAJjHv-Tx#cplym;ej)JJg@0UzI6bwLGXJI`1+b+Bh3n)b_2 z&O)zEg^W09%%)>ilNXL`r&f6!5R8_#QN5>fMO|?2l)>|kz1kIZO{1qm2wOYkU}mG* zIDk6h+Wph~8fSFC1;$89lUmP)eJ)&0uxBz66C>jRR$Puv^3gt*RpQn<8YLwtc2Iri zZ;kfi2zP8&uqH4tE^arAV!Y_;s1b@&y8KmxXLa(rVl;l&rC|-_o=uMZ{*PG%z;0v_sx|r;DHZoPoBn`_N&l{)GE#;^##|C5@s8S|SVmLv$hs#lljT@M>E=)}MbE zV|9&Z`<%xHU->)qC{j#8Haz14WX7%|bMgji!b-llXdshU>RUz1`2Mb_9VnSqL))Lv ztcvz~KmL3ZNr$_aGwWsGzMa`r`c?ply!5wwkyA!7iI~@8qNnrs;#q~aSsoC<4O8PF-ni3wIO`t zw-0fXo#y4VQdhBC>bh(zTlZiy7v!Y>seP=6xj&!mZ>XQsSN_KFAWBqv5&t8kJaR8+ zW>NWZ=u)4JSnJQQ$3@3fr1AXcTb+#(x?etpbX@ird74w$P=h_g_2$~dkUPflpkA~~ z9;vR~G^Xb<_`0z$%pJM1a_enNKrP=K8?unTjvCwD9)8mf=`KIOOTk|Ar>9FlX^sOf zJu^^LFLsTrXKD~5zo&}t<`Swsw-tW9(i!N88k@YXwGseBXgTW{_S`y#FmxV>AArCU^D+jV{%sp zd-mzh$(BT^joZub<;KVnEfi9LNfPD^8F~qoLi=>$aL{Y1o6051WPf!US3WYT$Sg}%)nW+3_bcBNqZoK zp)!|q6-7P#j<(`cz8P}3d;elG&xG9|{l)Mbs4Ewj5*Y_QL=H75f z^#FncD51c%^w}<7$s#*0TrS_b^_Sb@Z%G0ijjf`}d+go!Zrs!6XdAjFGst57%J?Jj zw`riEl496!OAKY7!z1*x(oVUOXKMy?8?l_V9WwhMuhf`&&pa1k2Uyx>rvy1RKXePG zK4Y`NwLbAunM_bFq-xTa-?T%fALQ#!tgMaZT6BxZnNwU#=b0;=H?`vs$qw>AA^as) z*YzNBb54Lvf#|C6BVzTObZZC2V1R)iRqF1(W+}#E9l$ET|$;-*^x~Zyn zr$H9)=j2>NL~bMMqf%&R`+%FZyNX)|b@floAMhJG^xCc^ZIOYA# zl(?v9477Tol)m|g8F$lr_4S}rsYH)glVd=R|DBZGM}_b^hE8hU=e7&RlS#8cyCRR8 zMC2}dA9vlP4p3JK2@xpOPK<`Akc#X?btE(i>3h7Ai zk>|ayJ3-aPcSmMj2OXc%SYb%p7QhK*?Bx9vK6jIC?d6gyYG?ga$XOW+{I~F@3pTqw zqES5@WGXCCEu^zzl7jI|x>xOBk!it36Uy8$F`~n%^C)|?6k8p#oCUTrM`S8IPn=Vz zY{N@l6(AX7^DJ!H*5+SefOP$4SBh*>f6ec1gfM>11>k!0;c8?4d6p{F{SJ|fn7P7_ z9ArkXfaN9LD!V+u?$(MgwmaGU0&oqs8@7;|@K(T9Me^Msw#0JOw6m6M3a+yU2Rtkf zD99xQGDK!?y$fige>Vs^GJ?t&nr~G*8(lj)`i31scEC=H810OBL9$>ZNCH zT;uMeIZ3!E);OHWLs}AGs43-}`NchY-nbhDg$#HO)2IDqF-HN{-mGBi?MuO>mDtq{ z*0*J!KF7W0fClNN$z0w&VXSqRe8&db8!DsQoUdQ^+j4AjICv%En&!rnzwzAzfB>@R zknLBxEofCI8D=WJ%rKLq2nc@I%;_3K5sweDO5$zrdErDEqKOq)dKUiaYx4P%lpJDi zAqP#WdV3x{Q;nvrergPT2Pa7st=JB0*Kv!)C4lQ&Q=MU6PNcOvZrRevXb z+_K=C2{j`PN9v+&XLwQMUiAe>*|!WI;RGEuocp3wp%%2vF9&N|PPYg=VzVAPhgzHC%k4e-(41}{ST(s*Pi zS|wK8cC6@<^SPWBr_R}W+lhzFHWbJh=Yb$N{i{0+KdpUP>c@Z;d_B#NJHI5fYrxYUKK z)SXL7vhB0lPs?Aiq@)u!+kkKYL8P)QAbw*ryzD!M$q{VoZH1tg4ks|$=Hqha{c=Vd z_G;&RJIU06r3qE&tA%UWThDSW{w;CD-)(2RF&T=fuzp&h&PbF~&0CbDWb~WFU;|$M3(Ds z9KmkXIk}tx?PWDFF{Umx*J0OdcB@G{C$z_0*l;Qk3bAKSHdV*C?JZ#kdoXOJ^iubLB@c21=b{ zH6L!(@Xn}o>XKF*Y@Rk82HW<;7sBSGJC;p2A_jvWwpHj5VlJ+w_IJo6|G-@Ad6$q>{OJAlF?sVZaz_0R zE8>`8L?T%;7yE5R=9zy{4;=e_tqlP57&H(X6_l3nWbAzjU~gYvuYmx#s-@`^CRpt#U>~@jIfYN$mFo zdpvnX$&4C|HpLNLc5FAZMGd7QLi(e59%7Fv!hYg&%Tbi^{*fJqA~0g_&&$tQI7_kf zM=Ud&X?6|J3Ic9F$NwN`^vvc#58ShLzN+74?M4!z>DeYcyb3q+_*A)+MtR>o8DrKy5Z8s*Ix7y$&F`6zWwz_ z9$wvp32R<=WqyOA=gF5)6Ex;X!_hF9VburTcU-^M4j&LI$K(zKj{3;Z>RmxE2rQMO z48>=z-G51<-}Pey|6J-9-0#%Ebr76rdJqUUB&IU zVPb-4N?X>i&%cWzTg5X6e+*XW>U=2-K}rfQ{niMe-u0V!NaQ@EAPvr7Kbf_<$L5+D z=H>fj@FY8LJ<<1$;7hi!zr&LA5!4XuMFmdZrM_@OT)Y>&8{35a{!1}!(dMR?gp z`!x<5i~SQ)`qXS@kfK#&#)X0;jjY8sYyA1ex%}ssz?-*~V~t6{5O zP{w7{&s<#GfeeiR6a`uf_%OPWBWH>NH-mCRO8bMfL0{57Zgj=*r48@|O|%XOE9-Xz zUh0I`8Q$pc=r&uqAEskV0}V()crN9fhKo=AZKXY~e!h$5GzihpsVi0f)SF9b1!B(} z6^HGDN$Z{OEeROyqBCJa7Se!WJxuFlv_LCF#v9;RD7caAFlp_?UKZKo&XXaRj$+ON z5tn5^4)(phHbH@!XI*oToL}w#sX!YCuqrV+iD6=qT^B0dL@K1gxMsWm-f)NZN?J|b ztJm^%_q*}Vk80)t^0m`M&bhq&VgqF8IYnRN=8$ACI)4o`Gm8uJzXp~d&x z6Hdx`ylhz%U&t%&2lkYn9vA9_X*orY@%#t54n*pWV&~QHA?VI8VJ*=d%8(&5wL>t4 z=>o)v@LTqZ?lOaiUibUjv3Poc4Ib5{T)? zzK@-GX9lz1%a;e~2R+g0>4$;%gGF^(5C8 z*l+G*n~Nn|BS-sQIRG@1X$WWL*CWT-l?1E%Ss)4C1BgGML$=>>lMo0Or;f&XQ!sJ} zQkuR|@XNb;qU3=jg+IjKPWtl`Cg_-6Qh@IB)^$OPv;TGvgm8~g^&~X)vh5F`7t{2o z9v!6%S3T3emlotz5h!|?v}pKeDJmH!*^rTuL*s7@)|6xFVNQe8QmV+cD8(@UvpV&M z)o9${wkfyWa#X`+KDfk<26U>4SZ0B)z~Vb*-Qi)K#}^Ju$D*+gFvc$qH&ioqGU-5^ z0nDxgK)O0m#kc*EHrTamJ_!|f_jV(ZEE@3^y5>%BXN~HSMt-H0vH^mTBX#^d^f?>X(P@Q17+P-|?j?<@`%X^V< zdAR<|4BG)1CNpO~_A1xaT&JzIzj56eG;dC#DM7Zie7lz=*c70A;DRi3zaN(*?OcGj zxdcsr-n_>;#ur{lCn9?u^b2oMD0vPG)T_49_vkh4eMrRcC$=uNLN8RA7MbN$EgN%z zbi5yxRdsmT{VCWdai3e7SGAxxqV6O2E1QK)Sgssl4cymSS(bq6vUX8ah)DB`Rfr(- zr+NbPqm@|^hxm2O=Cz32Swcoj{p@~55V%>Y3>(_ESFi{aO%h_HW-=Ld5+0xt{cqpk z47rw^3qiwr%?5~EDqzS`*3$2+86fJ^iAgDb^kXjDXFiU+1t797&2Uce&52HUbvxLF zRy>3TYM8O~7t?}Uu7(*@PFMc@Po$37MF-z-EDSMm^Ca6VGicATE(%%wh#s%2`0oVp+U|lQ7v|f zQUp;}kLe7qg$v z{CSJ8=BbFM2{s?2PgMVx3KA^9i6{j*SP%n?c8B;oXlmcV#)76k=`C8$H^PVtgxDD5 z=6>olctvnyCiFYWoyU-9@BR-N^Vgnm+KRP3J3luf*(f0Uh~FN!FNSw+HNl zkU&B3eG`#j%@D7+c%z?%A|d1796&Vi&Fs}~NeYL+iEiz(qm*9d3TMuX;&nHb+4cK#`ifhk9T_*BOG8<3_ zD)=O=Rf;{~^xxjJhh~yULE2$E?T^Lx!1XOQhV4u`IBO`$p#`ggb)yZJ7LN33?jX3) z9+yGV0)3eQsnTIKcSP#S@2dl6*Z21c!?uGeUStidOsBqF@0`4^j%DG?H0%;A2P)eb z@}i)Is>X0D0EyMAX-6MG)z~sX$J&(g@|A8f{FDRtkSPn`g`#;gXs-y!Y9ePc%jsc# zRH1z7J}HLG2g9+|fxhP09#S9M?oDd?15ly;uY(+WZk}qAaD@<3b!Gko>rknSR1-M| zP4h;?C-Py0-9!dymS(VB<$DKThQQ(4|lMoKJnNXADO5dC2=7~Z;6-P=o`gg7X_TO7)Q?ha@L;gUjlG-pMV3d`4&cau zW{HtlcSOOj6PffnhYt)fs~f#-d2G}1VVMe*Rc^3HK|CEfW&>jWurmEj@5{J*`yK92 z9YD-cRuV)8Eoc*9gU1FG95L#D?<%xiJ42E}m&^D8(=glS@qPXFz>lnTm#`t&TJvB{ z0HfHTfE`6~h)4DaMQ4#53vl^cdf}&QV(Z!iTi&+!B@@BryYUcny-z;SdD>xNQ9{Io z#e8ZRK;#F0g{(PJrm#v~*GhJPjt$t}&!8#MU!Y~Ufp0e5oNJMXs~-=qrXv>>e_&UV z{N3BTm!AbP6muc3l!1RyM}z-ts-}(RLiU*a34^DL@Z}EtL)s$(ov_DTcfz$v^lxKJ z!ggX6z#h&>0fM8Wlg)^Xq{T%bXKN5KVhbq)r^X*VIgkeQQ>dbNd4lK+2SIs`@QJhu zv7`mrn^#JWz?88dbLDoeQLQJJo8FVHJa!fKqNhoEIcRtFimW3@)C%A^-l=^PJ1_(!tqJVq43I7Y9#&Pn^4PsF3NL~;y{ zkzxiw;Hwb1Q-n+n>k$Gy?bOzh^T3ArQyiavYPlss@;vQgybW=@l!t;50E=&TAy^i4 zZ1jV7R!C9wL(JK;Wq@(i!ViV)XMjy^@+89s@&0izXY+c*$k)YsR0DH)E+I9Hn-cjP zB>cNp=dPVYR?_1LDbC`fDFa?}Hx)0C))*vl&PuGEL@|kaT_%zNq@)<5*+4J?428zy zv7L;5qjcpObn*B>E=x+v=-PVJ%?V-JsZALVhkw8o)4)hp=qV9Ru?X_$N=drNjr=-i z(taI`h@HLepF3ljn{Qjr&_}fpQ@o{mz^r}U4bO%kzr&nVpfeAmJvDb88j=IVVz3Uw z)J5$^bze_7n`9CMToeb?68Ae=xXTrU79#nz^>^aRRzyehf! zbOlzEYd1+_q{C&192tRKXW4Y6+fzqfg!k88d7})jQ@#)8=k@J~1+&XlwAZEWUoAGG zfv#-lM~J-w#K12lRULoTg~ng(>wmj-iFG;;G+$_kbFsKw#aBw~R-mke71M2#$dhR6HrcA@+}&IE15!3*&MD-(Rx)0kt6_?NVTWiB+IW z64>E)S=LFSUC$8KvP%i@fcMxK1qn3iCgte{ESJs_KP$hU)ySH|Z!MyGs|*>;-8doM zrHI^)s=hYS$!e%unt#BzZ(v^E?qV{6y(eZ-*`&~uGSN%yd94L_SW3vK=t!fIaW2J% z#V>hkl5RxyGS|rpt4bMTHuGz_;j#wAl}rP~=}kIw1KgG@XjID*eqjO&biyn-9wQ*@ zg*?MGuE@%-4PqdeePSYiIu|@K+Ac>67V804fop#kgcF9N5<5A`dOc0Zh+|nr$XI1x z-SR6(L>k~}YrtMzAM1qx-kq6{krvfC<-g4u0W=P&1o6Xbn6lX0t9g;3SWkDZf}WL0qzIT9!sze10Ai7 z7TS=~i~DB)MNf-^#`P9uwje!R0uvWw)XE$?d!5|Un9Vm#jaICz%xSOCm@P`S+ zb^$sfSmitdD8jS#MaE3Nau&%;Dt4y^C%#DPW4a8R^~@}0HA=!bW|JUG(!5w6z({_w z=ZGl{Vx)O95;emxpy)eA7MUK7I%SD)VPni{)Y-oW0metfumJ267!}}+3gi3gYR0@! zLp`Od0yISC2B^)~pRdWF%0=RJtkOyAVhaN=5f<1YCg1x6D;!6MR4aoD;+Y4O$z#}r zNp}>-8|+$6xTE~Yp-(BIyDQffPJ|76M1u=}2I_&33LmOfEJvCgZ`Eb4lgd|BFvcwO z8xX$GaxBr4LivZW6u_r|2y4q`xk5`B8Ub{3nZ>iTB-QTo2+J07y>{|t?q0+7C{>Ig zc@P=Ipb!^sny610k;)g)US~F^Tgp(@ti9*)QNBdiB#XJ&X4%k?M;$))^=ATWTs%fPVOl_cbl7$6U@-Jga$aCfLDDCm?b$gj|ii7dC*G10G;VdlwUC zAn;WF$-rJ>J!x?FVEie?PpSCM!c~5KC(IE|oWB{q*&%R34_Cgeo=1yWI6`D-u;up9 z3j|wkB%n;~$%+uQD;%7q2FQm$7;nG*T~8d34xLpJDmP2XbFwmUb@oC8h8(5svIDEY zn|JmKF^)t7wVagi6*SVKF+C?x={cc>I!X#CrU@HyjH@X6PU_)8c3!LOV!ry3yxLGA zkH^UCe6kH7$ho9rS{^(W#BC zxr%nVMrF2zB=e|A->2uivWmyo_J?bHZ+OTBMNh%F;dNqbH&n_eU#@I7w9JL2PlfE! zMY$FP_LXOa+$f(7-wDk*ex@{Vzn>f~KHRhrMH&npyzzfO{ErC#?b`xVe^jF`1Cr=3r0lZKr?c0cB*q=%l>4Xmi0 z*~EG6V7~M-qwn{qU`+3+|3V(5w%unAC+Hd6dfwpv{B4oE(<$+-o~uP2Q7^X3-3z;G zqs(jtvd@S4MgO*=`ogb87i+DxhV_}dXDLH-PvL05=^@?%_{BD7STVDJ6 z1Zw>U7u3y<=#*f#eib=Nm(t2J^)VIx7XSUsjG1KHF7Hy;iIR97-t<7XKb-S8kirc2 zOt24_lo7TT$t{KZ${>%T=N&aX_PBnR-?T2dL@n6CSJ5GF)9P~~JH8lpMX1yNpLLt8SrB*HpyS?=4g4{_KJ`R%`3aX z3)jy@2E94{!2Tm`Dz?|7Qo<$P4fp0!b)gvYnSs21-1^p;O+EVQolpa$x{eO?xSs(nG8&H9YY))&L1@TPD5*Y%iG&OnxcrFfrFEaoA0WKSAd5{ z&rwDuW;pvb0~2X?#}L2k;Pap(Gm)Dfc7A>ZEUeMcXv%_PY|*f2fSGOTW#{Q=!^{r; zTyj$St%O&GNf$iNo`QKX1pBIJ*M|6TrnJn*LI literal 65470 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 _X}QEx%TW`Ef>q=eE? zj!)6yK@O#L{?VDq-55erg<|iyTQ7r_1m=hyx-E`jy5~Scv-NS2$3Xt;VZSod_zfeI zifr+MSOHCtaQEk*?8im>*?kN@(NOI%eCKy#>~=4161M9X=@F;vSqnYh-{8Scxi5Td~~24QV>ap`Vvco zXUdVLvb>37p-ZXJh`K=#&`>16WO+Kx_5w&+Q>2)Hdqb_Vh}B?z>l8?KT%ey)u@iXunDSbR z8-pR0F35Cvh~(XoOFB{{!3Xh=?j<9G_T@rCI-J1NS03!|= zbFhJN9bSjk6COZ(w3U2J2if&E4OZD!o|VQbw)uz^|J`;*e$`6H7pdXkh9Pmj^TM^W z>+0p(A$;viT3DEgJ*)M*8Rzh)tjNhBd`;qBhCf8sZCsva;0@uaNB8MNNQkq$PuO6y z$~=i#K{k8RQEfa7;T`TQL; zjk$}AAaHaZFSCkuC?8mP61F#Uwyr?ZjTWPyy}IxeZ~{L3;SccmbV`(}TcI5Actd@l z9K`FT?bJ6=@Nj_2zq+G>4n-lek7$R$V*}C7YPlp|-?Nc`{IX%#V-Y{b zOMN93ZlGPL$=s^BHg05P%aMkaNm^Gomz(zCx%-i;OZ`ZRCI)}J>Wr>Ft9AD52aEjz zaMkJ%Dg3k5Z7=s{QbY-_kg;#6cB$+X;iy^a78u$rqMXKldYe=KTH+%2-(3U!mYDrf zj`ZI6holg))JY7!SW0bf^?v5{$CuPp22Bhw>4voufEhVCzS#JSp047k*c=wFUIxlOTGAft-nE+;>m zTzJ-xZvynO@3LnCq3XYO+|+m1!R)7iMuYUpSX24!;)l|5!}S)Y0-(u~{Yv#mL64if zp0p<~YUd-Y!@|C0HkC&`obcUBnEBgWwJxRka{!@XJ3xx7*tAJW^1LZNU=kj+3g&IN znqSM_2jEb%T^1K_gQ7fQNd0fVXrGnbC-8KwoK}O_mXe#cQlZW>vodTH>Oj{WqMRU_ zg^tiF&UC_)->A376NT2HnoXUF)0gcj(1P7*IAo(BP&x0)NsFaKs7vE31KyA%(3~$? zP!w0>TkbVn=fB8JT|UQXw@x|(+dPk0y-G#YCR#+F(g`ntsy9a>kC!F$YKhx#zxf8r z(D~EEcLYC=MmTV>$gKJwSsC5G8y%?LIh2BmyB136GO7u0iyPZi*v3CvhN`pT;9V_(P#KW+csyXp$k(tzINl*aRr(J*SWx%+g%0|6^Oe z<(;oZaq%W_o=KFxzRq_eH)n{%leP%@P?dSs`LYB^m^ou1%xfRXfKnZ~hz1V@Cf^c8 zR6UA^U{$Y-kQ!by!-s-O&3i$Cn9~mEGRa^6SuRP6SUSfJsar5il0p4d1`^NKPjJNq;VBFFB2gxJRrQwn^M zFPz0lUyzJ7YSzB}AxdsDy~SDZkN`*Ld7kOz0sWA8xZ3XZiF0tz>ET#Y)g@^|DGX0&vrg*K z*E8GQ9#a4!BmtUr+hJMT?gbMq{wYsd6CZ$qGGn=e!&x?;Ixa`S&XFd!RipOFZZCil z=U+JWe2P6{jHFaXa7D(*hKldZs$#4el#+sg!OL7C`$>DlpdoxBNaM=ei^_!8TFl7X zdq9o^G;Z_c46)o*#PnO2EJo0u;3uw$pO-d(xZaTORU#VzL6T~R1P>u|$1)dh(9+4^oT5xykZN7(8IlTjB$^O1;?qE^-EfQ{ngVB0#dVGL=uwr{S zjXL!`@Yurwh+o}dKz5FANAx6-!cxmD)x5ao8Ko_LaNMIsfxWQ&WqV|A21}zbKq-*# z(t5W+OYg_s>Q9us(K8@--N>5eqnGJHr@l_CqC-;ab2GaUKf@CgZ4$}(f+Q0Q!*SLs ziiJJ*Gm%Rye!@G;RGXBh6Vv_D?|>xR1_+J!NsNotm(pZX811!;r*pqM)JASydv_YG z3%Xqju0ljx%31wY+CST733m;~UeF92+Dzgl`gAxHl3%R516E>7%OrPcSRqK~?W53IPX<@4ToO&85 za{#$~e|ONwL**@c(GE#A-Qr;PZ87pjuj?uhRA4%@JMg9LF!LiA5OB#EKDg!QeO6P} z7ou!Rq5Jx7iuY?ikuhFCzzcBVi$oy)xX0Uzf!PS2cSNykelYc=P|DOo_SK!RQnEZ#Z z4%zkoU!ptzIe~w#ivN(we@x&VaPX}Ftk_xG%{pkjCidZ%p$_*C20S(=oV3+>=2h-X zwTi6cb1_LF&pa1dkzi+G@{1d)0Hy^)UelqDVzZvR!04RI)2-2z-N}{(w;%- z6DzVpA*m06vkFN~1b^Nb`_(EtKYmm|_MtpM`aSVG_F=pUDABMWA4CoSjipRmE^t9$ zBe$79bA^A}0t|Tbf+m}nXi;6Wc0~YWPig+@oXDEE*W(5 z^;HFxV%@fSl$;7wm{zp7i2CWI6+WB&2du!o2a(v?nWDQ5HByJm8tLmpzaf1ovU9&_ z_J0()O>?AC0|L&O!_%|MJ`$i*?lqy;%t%jWB&Y5Ec-*r{lgVv2EJlEoWxK--7Ys4ytK@{&t zl&1?S1b~S{2O1@~O8kV+SxpLg!JaezW&OM)$I^xwY3g77E4eyA;=F39)Rjqs0VuPP z^S1By<7cpFX0OAq57jeLEa!{?aX;C=z+&86rWhDEnEQ9VHlv(1U_XvOzJm4(EO4`?VE|WecC_W?G z|BLKktHi~lhi%Pv5p#f2*~vP|j&(SGHtY1&d}hUTH=R;)@%+cdk~qX~t*hH7t4GkM zO5S_o!%K3C=aaSPCd-P?a=?SxG$+~gxAEfi9{odiK|-q;U^ZSR6~apgEO21Qg?chH zmkv@%{OHKP9m+KyP4U>So)OZ- zGz`=|)zr7Phs+HD4JL=QTGmyvBG=3-t6WP?)UTH*?l?Xh>v4KOyzP59Tn$!0FgmPm7QJ@cB zLS*uhNBOPixbzT9VDgwU#|%Vx^N<_4@`<>m2pdFwVMw*o`(Ym-J$y7k_A)o{mou^N zT@p7VTy3HgEu;g4A2i=jnMo3(yK>$@gW%adOxWn3rYtGu_S~NMSJE87htOSfWZ&W604ko zBn@C1PE7fnvB5iIb~?qE%!oAobFZm$_H~lJmNT4~Q)UjpjI31dW3QD@oRkHz6ThjX^)+UCJcXBGP)W@l}qgLBb zRTl0Z;ywi`5uxaTCn4J2w96IiUgx6dA*ib}7w5&Q!J$Pat=(lvQ0?^l)srcbQ95xK z0>e1&8v!RCb_mz77cJ{lq2ZNRCK=s+xTrXcL}E*#N`PJyP!ab@Wr1|nyD};B6kmBh zG>whd)r(}X_K}fGrx|n}j*5WY(`RUvLGyjCh%N!h=7o>U<3sBfk*|m+JJN_Wn}LTb z`^aT8saiW%A@$+fWn7QTS#3WYEQ(=E@MEoiSQ}71k?)_0F_?S>{ ziFKp1?kGmaaT5Q+&-1bv<4Nk>dUw36^ zi^@cL{(}wN0Hjp*HG< zR#zEm0!iPL%zi6jc=_e3Muga2GeXsszJNaC*W9}qcMDQ|^?K&65t5X?_@iiG7-MMq zZS*ZTt~d4@8lUfB0z4kdC#`dlS|K8?mPodUSYn~4&r*5Bot1iOYpTTD^#`(0G&_Bz zx8XpCmwwBw^@sb+S4&vl|X|}BV092&rVdG+r)oxT)w?D zH!s#ca|$_gb#u(bBa)lQ%FHhVUz-aoQn$Q^5x!6|E}xOMXvHtSdL~<7sUIx6F0(uJ zu<#^JvWXB(_E5aY{bvnBrcn%gXvT8`!C>!=Z#i0mpuwqrgg|=_zaMc+5ebX^do z8Co?z8m_@V{%7c0qCv3z^H02kvHhe+P5aq*&idn5_S>KhL;YfUAk+FgUP1XM>Z4M2 zeXLoJCHOlXAs)(3yYJKiP2VZfH)AZ!;AEl9G2Bhd!!TMd178Wg)Z^ds{1Dk%f2&)g zzg7^G9Tj+QNB~QT`aXZqq!DUzI*4zWS(sRi~mLrELgccgmc$&@S)xyl5F{=#>hV*QP5Q!N*aA z@6BQs$;jd?tq!ZS#a%X{8ejuHn&VD)qtDnLGF19AF#T4(gp+b0u20H6!V&ncUoqzB zDxlC_cggRo-BurOgG@Je&BTjT02qe|F392e75HIq4HVRQ3q2pzx<#`;4$0qHE}-Q7 z@Z}G@RYyc?mk1$MNMK;AQ{c^#R{S{xXGB(aYF;t&<5Mc>g2L{V`hVdkbi#mmm6F-r@jV6)yMM7*?_UTtw4 z4lMg&p?FK#QX|^b?OnW3`*<2*5T6cCEh&&>S#+@#E{E?jhA*vwNMECH(lgi5TJlnl31=CCkr zc~=0}GQ=jPvTlLG$1`cieDZao2)*PWffL8l?=t+Cb7)*#PbW8P_S=Z-SZ#&b8J?M< zYgs9a$8XQHK;9ms%|YC2+~XtVa@Db&;ezb>C*fya-q#^X^%@0hCpsFta(KJd6WiGj z?PToI^IWB$kOFx7C@A^dMZJ(|&aOGUZ}TS!wQ{^$4MAKvyqvwoMdeij_6|1DJiS^G zcac=7hvB)u{XgMNgAB-F8zL8h1-$RaWd3B@p;u8xIp4)gHG{G;aE2$kqjtee7Nq{< zPVe24`6IEjlQU<80Boak{77am`ucT>|g)i);W@Rx|O|}k<4Rq!z z>Nm=2{H}nIIMPkjbS!S6UNsaNlqZUm_HVEooEeSoPewKrfV|K4Q;4PeOvJ*3b|`YV zpXS7H)GM?`yK83U6Hi|ao{TWF96l_bvDp|I?xnvERXLJ8>WLwgjBTjYDK~ggTUM7p zL9j1B`F3=w0YqCcRl(at7JwKqd-8>g=DB%?dT_a5*Lh~~z^Lfw$g#9G#|-Afy2&w# z)}Zbc!JIoGWV8_TzTv_AUk4(fsE15vMaFfZl4$c7ux<6oZ8AijjMx1{Z}3!V zS_hSmc0sZSlEdQjP6+aRbxq?5B~_DNLIo?u!dz*K#Q}Nn@(r{92lg)+R}a?3ihx!% zn0LNF5Zo479|smy7;>d9&Sy7RSS2k*7m4t4_^lA!Zw^i|naAj6I-j?n0z4jIq$4CkDywe0M{*=I1^Cv^rcTfWE8e0WGwf)k zfBKzy|0WglE6?f14%IITmKf^?|fL920GECg9`Ua-VR>nNjx>{+zOPXOMBmf6K zcBoT*dA@beBaZeoE^X@9w(pDAac=!aVLkixy-o(R9EY<@HuX?O_8K_-^g9ovKN;QYe9`BJa%)~@f z?Wm5Q4$0C*-cpPpeq3VZSN8JfH{msSa0VigW17z%bhD%!H zoc1n+x)8DJ#;vW&Hn`bcliV{)_EbmCIJ2h8kf&Mwq+jyoo+(?Q%m9PgIe0TOo;Ku_ z1W4%X1&2xV*s|K0%Hq#MRLJjN2Ji?8o|qOZ@+;)6pS-&Bu1Ld{MgwfzYA$+he;_v# z{mS-!cTqz34!Vi8{3XYKj>Obwv>xETFb zULDO{1ra)3rSzlL*@Cq*en5CJqDgT*;7fX|s8Y8IF@C@$tzHw1^1TU)@7e!LtUeDk zlsQJ+l0js)QzB`kxpo>`Jbh!y;hR5<+bh$^-J)tq4dv%ayQQuM3?_sS;~S>oVASwU z(8hkp)sz)>=ke-~N}Bz?y;I|~h{8XT8ssKf)4oTp1v`#A;LkR%N=<=SymQ7iUDLIl zGIt91RJKz$+qd>{jJ)ncNtrCfn}F~mX3=Cs zsQkLvTK4SM&ZiPIrqFcr^KQ3-lk>^F_hR4Fkir9g<8RNhvlbQf;I9`)m3WQaUG#k} zkJlIkS4l|Uy8{LB8gH*Iu?or>?elf4RiO-Qz?I!SY(24_tH!cr$o}8+%4SG2+)k)H@NnJB(KW=*qoNc^c^j& z3zKRLo+gsy?W*CX6iS-Yk{wzy7VQEejPfGwRR-!lDBrh)LeCiwD_4z*pDsNDneBAI z&_`$(8Np$P`nqKbt_IPtaXdEMhz{jLV}$S;K$NzzUm6-oQ+U!0xz#-$=?~P-ywcEb z5nbO1F7&dowo9-Cybs=P;|l#%i2KFm(6F$Zm);UAu#qhSX!_5J4u(JBRLE}yl;lYb zUMOuY0lX#b!w@oP4TE6qzq9tfoIU7YB?lC{;Bh=NfkQ?VIk2qI0RDVCFGmO*N zEh(zJ!0-krGweC)U13)jujq%ypRX^I|ewqj?d&x8aL8(Z!1`<8{W5z z;6-QWH)?jrQzFOIDH(Nk7mKi5$8Y**;!W+q)txjawV2#>o63Ft774VEY$!R1Sy1GL z3rF#}zB~*a^SSM%v?i01@RN`2Q(32RMLIanV`3O3(x~!+{B5*YZkzGJpL@9iu@Q1X* z&FiGcQ``D}d-_rCT(GS+%8&9U05M`s@hR7&)C%m5izstdZe|F}ex_$Y5ce|4 zYcvL5qDP$D_a;2zCu^>1>$&ypZHIv(HvxGld0~C{E&DBAhbw~%PmCHvtd3ZkMMvW>c1 zy_IVm$%pFtDR})v+al8`~djNEsK!s}XHK>?^MY75cl6He9RlDAx1d zWzLMnkGJsTnPxA>T^oy#ZCL4Vd$T^^haTV1vC*tkQX{`u?}aH>L8{KJM)4N{egwJY zL+T)G-ZK{=^87w^*j>FivE*ux?n;1RlI70jyoy7Yz?ngBoBlY8ei??_kp-a(SW#i8 z6kIxTBiPS=gJEFIz>c=Fm+RCi3rp*&+id$tM>zFGfZ)_s`QJG{^S|ID|Go1!|03{z z14q)WCDHu+)&}`Jp1lB&FSRGyk1HNpza*b&k_Lb)dH?=Q6Np?2^v^R*Pd$tNA{Vm# zpJ$pf5k;0`$=wq&$BJTtdbx4WAouj%Qr%K6@8Zc%tG@ZNy(;3qthu}QmFN$2d{4ZI zzIm)M(Y~^xzSj%|FBl97+CF?_n6^7nbdUAyL2z{LrJ)nP)EwM=&|hvx)6b9nF+DN3 zZLcF4?0y(pV^4*Arm5_3&Z)BXcsc}x>>tKn26zDgNtTWba$NxaZzNmPwE&I@y)g!T)E}M^Sdt$lTP%_># zA)9=*BVATv^;47dUGsxw57ZBZz5}2liPav5R<5auD{^oO(yEO9(r0lads~pZVg$-Y za~i&)!20r5tjnBM(o^5X2Rmh6S@@RkHjoRC5O4im z?;WL9W7e{d~)?r9ZI~{byw=>k!@id?TN=&=*h~f2-Fwemgk$#Y}+M{TpglC z^ZcU#&HFndqQ0@!^s#pYsY0yOvDd9$FVwu!z1Svx)L0!9s}OdH7f7TFw&-mhp454p zDK4;jb$Mfe@t4g`Np*9q0&h@s~T4Y%7Y?1~d)#RbYYKTqN4v{^ju z+P?1_->7h5+3zi|bKyICe6YF0#6qRSk(jN@{pp4qLMNz6m#$ELSCN<56%3bwHbm++ z{I5;;_wosrlPwl-bNj+ywoLc#C;{ILcf@TQIOQTyxL23tT{fw0MSMfd2NPYRvk<=} z=SUINq)QF8QmM=D=)_5xSV8ckz8#|8?9qnSV!z-)b1Lm~E{Hq~XJNNFv3@a>h~YDq z?GP=Zm~`lfk1gj};v0^*Q*QZ8u!tFR-PK~v(}?tT0r%(LpR0#qx5jP-KXZ@1n3EaN zmfXcXnw@K{#`HCl;RO24*cM^8sbdMfw8CXJU(>>Lzs@BY}AB(eOm<7Rm0=*HHE+E26OPlx4skj*2d-5 z>xH{YD+rG2!k!5BHybv8Lg+yDsyj1_{nWPIYnZEt&)_?XHGPxj9;H;}EMYDA9f;Tc z$F!lQMIu~IV#{zrdU`Nwm-J&=cu>`bA{1xVoxL zHbsFtVb(zSSJhL-+J}_)U&4xyOrFJ~%3dgd9wk!Ot2yOgcz#Fylr)H3VB$3CKW^zL z>M%nE&wP$>88uLR#Yz}Io)+c$VXnV3BOLVg;E35fZ7MA)Sh(VAi`MDynJix(j+yJO zYd_&7_Z;05rr@qNDbJZCd|x$&oAi8Vo!oTnd$w=Cosf3nZSCBtmlj)C*>9DA6sHJZ z51S5Hg;w(N9ovBt>#5u#H>WDUt3q_$(Q@xyDAqM?yL%?+J)keB=O=s}Z>_Eskk3AF z1b-f&KJSr@&ePxGiJN-npc19>ye9{S0QaHK;00+|&q@)(-vge{daNPQnZAKn z?<=K{43c|-&hfZKTSG1T-Y>QA`__)eGZ$aeG@SOvr`*Vc*XbTo_r0)lCvuP;aKL9@ z$FDb5N0WX%xQh$&?OtfI4TgZCk_eb`2DuGJqUQq4c2nCnp4#i%&I$)O6hnDkKGqJi z&)-!cbu{;-lUj6W_rOFAbmt+rPrlH62V$<}Do8=7& zFfIn+w%I1YN>%f^@6=>ne!9@iEY=Iwv)|>)+;B~NA~Q_1sg3bd(eyILBDS1+-!*SB zgmM0si32=Ld6eP{b>C^tf;@stL{?L3i=keMDLLT#l?=?lydwu@-+6hp$<195`PAQ-E( zCNAY^_MRcHM$5WeLu8w-2O8FUpZH+pXa8YL%F!&zy3mDW)%oBauBI?%<|s3d^qjWP zE7!UKZY1J*MLe=asR8F%UMt?lb!P6eGgaAhf8o?v!)CylPiwVhQTIvEJJ*u9@$brULdi=G+1 z9CW}G6SMKMHhaO%PoPS&@uIkI*wpC`O_y<$g_gPi{STIF=anN1m5vAOTJK`STdT=ot zzO{16Krw4YtQQW5Z1hy8aS5?{Oj;Ol!6urQw2|99)uMJtm zh}Pd;{PI?(!&xkz<$K*yzs%UJj2qsH?nQ)!-jr|mh@q)y z_UsYv@alTHAf5(A($?3IYVE_HhcVe@9X$KTyAS;gzD4Hn#yTGq{0Pq_Wf+Vd$H<=F z(^&|E3oePgO8v8%y)ixzD~#{3`tm(?T`1r&>}pbQdm?y&=Npwezeb2}=k7Ka__puEOZP)+qi8MUdkN!SN-y@9UiJl5KKUqk-pycgGX2 z-`t~8I{0AKz7&%WOC4DF;}@lP#RHB!#*av_z4j6A!Afi^RLOmci#rKAAv!S`d2e&( z8Ks9m#H^{jZOxTn%M$`Gz`T6E(-r9~Z5G5$ylmS6_F0jdA=svdYdzFu3M`Uekq2HGh zXj^kcDTP3l1=d1H?y;lRg$^%}nu& z@vu162l>M1qG^DZ22z8M7K!kmkv^kfiA!)v0E-KuzgLxm7ut(j@y9tovJbUCA9Ewc zzZjXW8dOWG+UDxFm)pLorQuIr zR$(?Z=j=DX3DkUcDfJAQ7gF+j005T&K#6RXllKkezZv-dD*;;gPqqV9?ynO_q+Zkh z(g@nbQa1@1Nk>-XLqtDinCu$rSj3SI5kV`)dQrGK*slO0lw4#!)=LR@ABNXnu=-$_ zGdURg<-HDiZ|cS+z@Y}Xc%wj;`0Q>MS(CzX^0G`4lI9`+{K~1|7vNfS`?psz;$MK^ z{OTcd&<>MpVYOufZ0W*3Re3pq$~vV>dBX`H?+w`SG2G#phmV4Pb))P!IERmnfb{iX zZhXxLqw>5#^Jp^1#eHkX+3AZpg|G${!e>QGIc-GT4&0PC_r1=G1j@gHpvvY@fcU$W zMGZJRHoIG4Z=g$qq#`(dYO9sk?}g;2{+Mo~yA(QQDzYhR+pT}$6)l5lcZm-An(ll; zTD{ab6fpa~l~g!(#`VCw`dsQD{~47T+3Z6N6wa4fw|Pkt=^1wNt)K$RE^J8mwtc-n zvZ#-X#xm5J6bqbjy%dL^#P|kCuxL|u4Q*gE{QUc*_f2%qp;w$#eY7kfEGhxdZ!>%x z#g022;b1YFq2kpHu!<@253Q*G@OQT4xN-)GJ>PWIC;x)|)YGs)xcP*Plf8WBd!`o* zu@QeLQH2U3sx5O-R%!#HzBYLW4ATk(_H{YC$Ja7X$%EbJ0T4XB|GN74MyLm(cJF=x zXoq2G@ymM=^py*k5!0d<8-bSK!I5w#k;i9?#AeIu6(1=|K~*ASB<5N#SMCKMjT&C5$+-%Ju1FOV;jrkw!y?0Pk-@hoj=|!3% z(xfON3Q|MwN>c%;p(7w9ARR%9v?vHDO{GRUC>`m&1yocBP3a&-Bp@9kz1Qs#yG>6owYvevp&77z4tY!WABS@pq=GU=W?sfBfanA97~FBN39)q1}R=@ zj1AaW;qX6(9>N;P+OD;9*fk)o}~o_>@! z`|z#0Qzj)XUn5iZ6jR$f4HGe8kuN1Js!}eTQ*&;ZP8d#al5HD_nZ{q2ZT@Bd#xl5= zo|99QajWI%?f!J*!vpJd%qvYDt`D;>cUI;Lm$%8L+E$gX>|r<-*+U|NW&#g9w)XCR zC(*r4p-1{3&R$EU4scvH z|ItvsEiHP*vSeRaV!NUIk8ellWIVU~%604K>&-?1lXDveL zw_;I^3D^7b>3|Y5Jg-jA;%~*(bERT>B*kQyr7_KuIPLpdaN|Bk>nG~`ualFOeP1yJ ze*FjgEt64ShlQ@a|4ck*fLUWXQ1b9Uoom9hfNeTqv#m7}SxdqsxruHYl95>OI6*-w zA0GeM*5G3GyL0(1uSVm6Q;6(m8jr7I(;59l$Rif62$`J|%m}rI6vS6~T2N=1uBJsX zbDV$dBAP5?BeYJN8&*tHCA(|9)VO_|0I!-mlH6Jey4|R2AQ0e(n9#96c>9r=D%Zh% zlkQO6y};;zSkG`?CIoT|S`jwT{H%Y5X7)HN?!Bwabhk9&$MwEX3~xlc0@QR@tgJlN!xjBiIzwCv@2unzd;6_& zd-zm#8~#4PUH4cxb9A`XMs*{RDy1CD*U%H}^lRc4ldsj$+wueAja%MtwD$tMO&E&x z+afXdC7(H+ZdNdPuSWT`3wb2e`?le|n|2v_2E4AIBSrnA*tay=+R}$ZID(ql4%G+j{A1d-!0Wz8LQW(7-L2<*QpJj_ zPt8x>t#XKMONny1)~#YH870MgYDswh{!6#5WFLc1)jLl)?N1C0Vv^@+8wwuY=2{+< z@X=qPz-ur01~d3HcFDB09_*8GgkPS`IkGl&d(*prD{#9gBzSFjVrJiQtG^)EE<{_7 zWNqGXTWjkf)$)))UJ7{J!CJpHyNC1xCOdNc<`-wJ%Mx$9Du$Qda?IT~we;nx@;uHE zx0#%>Bchx)3`(-5HuvtjVV#8HJX?EqYwTLL4nt@u#bI5Fug(DPh=A8s=>!aY$kf_) zTyAx0FWW@t{qpjpkMKE)XI>_(F{YN~fjF+ywI#k?EEK)Bmj*s2n@5i^{G3x_zRsOQ z_8`G^s@ZTtfzDH5s$pHRwtM7ihqq*TwS~dB^`5v>J^A2|53Yx--C}&zUFcr6apz?< zTenCjok_&<=J)cBJB+onc#urTQq{ao`}wZT=2qdpNPfnf zZN)(CdFSqRHh(X9r!RWF_^ON7o=qJw3)yu0gpcL789d&5_lf!XV(QnMO6Ke4Eqtzf zwf%uS73b1+TKY!T*tv;kd)Uv~KEG!gDa*vnn^JOo;hC_Se@4U2i8;<_H@3Ly;$JF^ zYSt~oDYLeuO2YQ(-yYMpeJN{!K0hfs;;}@JjO2QIxQrFxhV7&rJ&^W&CtTJS!u`Dq zbzGdyOFuU~6?VQYP+3&faK}@}BtqE5!0OSv=$81}-H+N!0tQq+Rz&wryG4;he~6`L&Xt?c4V<2-*@ux?~i;wuC8*F(=zo~x`bAK*yqBWo0&nMNl8jT zPJb|y5BtVwTSt}#w{N2 zSw7%tezss{s|54D#Clyo{g6KDJ963hhtSeG{^SBJi%re_w#Q?>zd`-)RkPF%bn704 z)rjiPK###A|KAsr?&JYq(6Yu}w(Pci+k?YO=XRYoQ_d0R-NBxIdX{kxQpNGM(>KV;=i-7KmJM00EU=&H zE(Zyki{`ez*X-bw+&1@6V4?~jRW59Z>v>R7w>`)lIeu%1orhA}DL6qtrRq3YTfT>PSdT<^qthmS4MYl#jjhB{BypZL^Ptd zXh#jO0#p4r`*@%)&#sGVvNfn$C>=8KleZ`x>)TSW%9%`T&Kh z8<*?pZOk;h4|a5r=pY2C0&(Af+}gI$i!q(bu#f!2flN=1*etZp|LRC#?S9eHN6y~3 zHML{pJ4dHRnmL%w4T{oVUuHW>YF2YGpzgFp)-Vyz^Fzd-@~%HS>GS__yq_#`LjSy8 zIKcCUbPz}+{-sAD3LXacw@2}}NAb5u@wZ3uw@2}}NAb5u@wZ3uw@2}}NAb5u@wZ3u zw@2}}NAb5u@wZ3uw@2}}NAb5u@wZ3uw@2}}NAb5u@wZ3u|96iRpUE;foZ9pYxTiv@U~CyN4~F37Oo8x6ygmmbHw_;!kMlR8OB5Ww;?88)blgS zYE+roq`G><`Tuqqg?!$fdFL6-A?@P$X6k9haAihb;(PM8#iUoZojI;}yx0rwqI7yJ z(|&E{PP@2G;fI~u>2EdauGif#xs=Lx4H_0xnrf@0d3~FFd|GdS`K0Dm*|Rd%j3vq< z_TPukJcLj8FyN^8HuhYQ4KArW6||CvG&XWJ%r*ixgAr5rpWN81uY7=ZPjydrk8*E* zrdpOO^Ddn>Sjt9thN|82$NWgNS=RbxKZpkx7{r%&D%6LPf^v%T;((&W2)W&buj{?1 z)7#~Rq9^a`Vfb%}etvP`85iA|yPp}dlSa8>Ml;p=)X62MtZR+>Vw2YH zsob!!rt2J}3laH26f+X4o6ftEG2T|C7}c9%5rLsOJYKLw(q!U;IOf2#^(dCp-$f-4 zuU!276g)|9U4td#p}05cH1h%POkjK9y_hJhU8n8`X{$4w7g9@Jk=FdMUc1nKx}C$f z?P|`wP@xvSqSH@W$ z@WG)crTZpFIo}n*%VoUmN@%4MWHa}=bAoAY6l_k}u-n)|7jIfB zL?vgQJu`9!e|Tw3DH7^_yf_zavaKC;a?M65H$wiZ z@RHE2(^3(wr^C;eWQDYRxZ=hTl*BV=JH&VUQSY0C_TXf%qh^7q?^WIBUPYaroMB6R zdV>6_R&qdg%iXOP2J*Q&PH?v~@Mzg9z3=Z0$Q)A5pPUhVj8 zp%*r}7|h7C3Xd`@hTQw;OV`n<{&JnZPt<$Q*Zte;b6qnK& zY&=rlhvQ#&>7Vm{YZ0|=Je88vpd|5YThdquBdZ;BVto0#4g(X%B|UCp7H8Zvb5#tU zt?{UY(K>*2kba}b>N*9G~@9pn{{cI_}=3itk=(SZ%)z8(x109VPT(*%o6aTJjY9{=k>?k`Tc#zb0iv%u(sb(=Hin zGJ1IOnA}gq`Wo+QOli%NWht#Wl9^}y-`%6n=sBs~er2U>-0wpEd>~MeG}%-9a_fg1 zB$Dnm*M)8v3#C6uE`M55!63f$Ypf@7ZQ!c)eVwZhnJ8-1bcrW^-9mlDr090&Wk%tJoXvNMfcr@uQlhf})lmFb({uc)L z|6CRuCGGpBkn2IADZr~NtdU2X@W5-ZCH2hjv;C&v^x4Ts_!;W~54Yh{yhHhE(VC3>!>715Ir->spvXLO zzt3HcoLhMJR^x-4OXOBx!cw*1LnV+ zZ^19`Joo@W&rSc1HbRTwA7N5pV~+YPTX z!$ajOP8{sczr(39ZIL3OU$?nC9{y3cr_$@}VF+ESd8ZI6uNKF?kObeU6V~ujIvYI8FYDOi z__dnkm`*5=&S^5+^=W^75|`4cq^6ym&+nU=a?Xb=YPOSaNd3q?gTY9XT+_cKv_^O6 z!d-9$+o+e|yyr8!+>Eidl)SjI+E|-cWNz6O_AvyJ1 z+m&JS@NwY-^c&ao3r%ou8?2GZ(W`WB!%?Z8%tc7|qTxASji6s}J#!B#@n=*q+#<`` z3;fe1)Hb&;wTzxYyu2Rf9#^Zvm!|{YMEL3Rz>pek(sSDS%^D0VNs{wPrj!p5d!|3D zEUrCQE&rs|=ixd@`%x{O7S7)Kj)YAt>HBqG?bN3NXB28bu8XC}#BQ$kpKd6qLii2d zF4Mj7Ab$o*w`Dx;eqP@j^RkGGGIm(wDPFH#N$r=fiE;0KL;P2@$zv+9&`TLDR@Wff z53DldU;8=ja~oREaPk~lsvaK=Nt~xSq~XX4ZKU#lDLI+&<>jnap98zMT9>YT&Up*w z7egEvvz7^V*4m0$yDv*Pasm5s?S`m^!8hSL7E2os<$a8VY^RQGmM5EqXr___>1t-! zub3EJR3C}zeHc1Oi-(dJzF}wUhUDp7k}tEh&>jWPRk%rt-9>(7J2bOnsC39uwA>^= zb7#tR+AL8`b@LRa`m|`G%abBEe-a0Qp^20x?Q3WF0>fSMU8*FysV&3ZmL7fha7Ov# z0=)dcj357)ued{IPkZ4iX>nDi|Hok-5eU*||Brz(xN891Iq)Y?4(1RDfgr81e*)#@ z?|yaZa6jQ`4XRrxWtvPHF|zma)K6%y#0@^r&vf|ZAv^S>B;K*0CehQ(+(~ddA1;t^ z7d4GC5}=z#k#$`P5liq(&xuHe9z$sEdA|c8XVO>8%K`quYwlCSTaA+Qzgsg#56qYL zh8icS!0ii=*CZ(cXB6T8kTu~C#Pa7a;RTQX)n)5HBmUpV{R8!XI_@8+|J}O(1(tu& z`R|DKe;IW|ybQ#a5JSRBMe@QhQP_P}rKFwUq98@%60easG#UD7*Is?o$+!wyhPSe- z*Uym*1H?3;+70s{x%m>M-%c|U}pg{&T zi%3+eefe#_S6}ly6VG`y1l5>6XP;2dPYkp*lS8s=IeB!`RC4#*07)?>q*sikj>ppy z$GJsN43XnPw+Wv!D5$Rjb4D6N+@?>do42RWj$}IhDr+d{k`w97ZHiriz-}`=3x}6hst1t4J6P3f2)y~y^P@h=Ygpx`)a_X(*p?LwRb-k;lBe#3dUN8Z^QGknGCqw_!xJ1tb8mg_d84Z&nDY zhtTh@2?z$=xjPRD%YHAn!IkI*e`^hwg-kjTdKomkw{ePpepST;O$Mb7H4u|=raByuI3^Xlfu7A)yg-;(MqSg@q=c`@Z#|+w z2tuv#ZROV4FugAt=(&_cRutnpnwFu z5v-&EJWUD^pw(`5bxzB`S|GZ6fJixfB5qG>AcZ5G{&I2OeV5O$<& zUd+B#ba;FTrUo9QeGe~|(H$U<&yL zVEkkreSZ-`J@zY0CEsgZ-d?;wQq$;v9yi-@gxT@v?du}>vWAdyvX;f%HuBgHr(b!TwLBu^C*SK9ex_9Iv( zYfKccfqsV8ghd(>hiU+gJWTV|#Mz>?2Z*3(d?nTj*}}F;0T_YO8-wA0VYT3p@~q{n zy>IzCq=Y*wQ7=cN@gK1|&c{TV63E2fRpG?m2Pk(6sH~}#YOOZ_R;eM)OA7V3GqyCU z1}b6Nu{!TEHNzU^fKeq)j7$35ye2)S(nXIRHpmWWMh&vU6 z8dmzS9%x!P5!C$93YVWRd`t#5u`mfkoxG9(;fwM{8kWm`6;2ka2|ZqD&s?MLqBwlF z2tkPzE(1xVipll*9Mhyw-`x~sOZ+H!20ey4HOWr{nfPbbs!kiU6WBBA^jK*z2b}>K zVBboaH|`X=^eKA~5z6G36j@+NG4GHO1%av^`z2UoG4n!V$VAP@Zu0fsvp-P87k&0h zVOH4UJUYPUTtrao;m%VuFaKkAVu+>i7uo>k51!b}!ozGonOJYY#IOoMVcGlKEuA(cGKQ@JV>Pv6ZL#jMnRMPVJWPYGcCK52Q zsm|p$)KF&DyY41Y&lG;{CNhX6VHhb?yGH@Bx#m?M{Yv<`Cy1aAopkfZlK4{e7eGlf z>j!=o{g?Pls*~Mr^234-FLkZ0~iAr5h0{F&SO|0WjP8t+VNn zE(}gY4)u{A*!Og58IVDk+bKpv`UijZ_~oua%#n^BxR|ef)v0hepRn;~lkt0ZkQMw8 zWHkD-qUKQ?zqN>w0TImyIWemqEnP5Jc}E}|x!P$_LI;qr@z=q9aL*=(xH}dGHZPzL zU`a(_pVlUdFZQ&2-(H54qc)bh=sV?K*PCzvt2Ddf2IGR0K$to|I4gN-9$_OJo6oSK zj=;nE93X~b_Ti6#&HZ4)sU)X}zYC<3ru#0MHYW(q4W`>x>?!ej)&cCoR(a8aVtIs( zSog&cM9LxF62@-VQc3{HG7US4IU)ffsY6`H2rLmd`NQ$nQzsCV-S0!}D+i(#4F8_8 z?;sbXT=jDWibV<;0a{}^AL-y=02PO5W5jB૕mya}XAM#^g0Y|`D`eg!LZ^i^L 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 B$)*4R diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index 78c4c7833545da805e10394696bd5732dfdba926..bc81c0166ab6bf4738469c6302e8f39472b65f2b 100644 GIT binary patch literal 8289 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 literal 11591 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 Z?Dq?7lI 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 literal 13516 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 + + + diff --git a/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md b/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md new file mode 100644 index 00000000000..6ea275bebe6 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md @@ -0,0 +1,114 @@ +# wp4 — one vector source for the icons, and a verdict on the widget + +## Why the icon set needed a source + +`desktop/src-tauri/icons/` carried eighteen raster files and no vector. Every size was an +independent artifact: nothing tied `Square107x107Logo.png` to `icon.png`, nothing could tell +whether one of them had been hand-edited, and adding a platform size meant drawing it again. The +`.icns` and `.ico` containers hid the problem further, because a wrong member inside them is not +visible in a diff at all. + +The fix is a single `icon.svg` plus `desktop/scripts/generate-icons.ts`, exposed as +`bun run icons` and `bun run icons:check`. Fifteen PNGs render through `rsvg-convert`, the +`.icns` is assembled by `iconutil` from its ten members, and the `.ico` is written directly with +six PNG-embedded entries (16, 32, 48, 64, 128, 256). `--check` regenerates into a temporary +directory and compares byte for byte, so a hand-edited PNG fails instead of silently disagreeing +with the source. + +## The geometry was measured, not redrawn + +A redrawn mark would have been a different icon wearing the same name. The shape in `icon.png` +was measured instead: it spans 58..453 on both axes, the stroke is 48 wide, and the outer corner +turns at radius 135. A centred stroke therefore sits at `x=82 y=82 w=348 h=348` with +`stroke-width=48`, and the corner radius was swept to find the closest match. `rx=127` reproduces +the original to within **430 of 262144 pixels at 512×512 — 0.164%**, which is antialiasing along +the curve rather than a changed silhouette. + +The mark stays pure black on transparency. Both macOS and Windows composite it over their own +backgrounds, so a baked background would appear as a card on one of the two. + +## The widget question + +`OpenCodexWidget.appex` is bundled, and the acceptance note requires a verdict either way rather +than an absence. + +**The extension registers, and that part is settled.** `pluginkit` lists it from the installed +application with the parent bundle resolved and no disabled or ignored marker: + +``` +com.opencodex.desktop.widget(2.61.0) + SDK = com.apple.widgetkit-extension + Parent Bundle = /Applications/OpenCodex.app + Parent Name = OpenCodex + Platform = macOS +``` + +That record is structurally identical to a system widget queried the same way, so the earlier +working hypothesis — that ad-hoc signing keeps the extension from being adopted at all — is wrong +and is recorded here as wrong. Registration is not the obstacle. + +**And it does not appear in the gallery.** The gallery was opened on this machine and checked: +OpenCodex is not among the offered widgets. No `OpenCodexWidget` process has ever run here +either, so nothing has asked the extension for a timeline. Registration and adoption are two +different things, and only the first of them holds. + +**What the signing state actually costs.** The host bundle carries the linker-signed placeholder: + +``` +host app Identifier = opencodex_desktop-b89067d97e1c189c + flags = 0x20002(adhoc,linker-signed) + Info.plist = not bound + Sealed Resources = none +appex Identifier = com.opencodex.desktop.widget + flags = 0x2(adhoc) +``` + +The host's `CFBundleIdentifier` is `com.opencodex.desktop`, but its *signed* identity is the +placeholder, its `Info.plist` is not bound into the signature, and it seals no resources. Locally +that is tolerated because the machine built the bundle itself. A distributed copy has no sealed +host for the system to validate the extension's containment against, and nothing binds the +declared identifier to the signed one. + +**The verdict, then:** the extension is registered and the gallery does not offer it. The host +bundle is the thing that fails a requirement — its signed identity is not the identity it +declares, and it seals nothing — so nothing downstream can establish that this extension belongs +to `com.opencodex.desktop`. Until the release pipeline signs the host with a Developer ID +identity, the widget ships but cannot be added. That is the finding; it is not worked around here, +and no part of the icon work depends on it. + +## What the icon check does and does not cover + +`bun run icons:check` compares all seventeen generated artifacts — fifteen PNGs, the `.ico` and +the `.icns` — byte for byte against a fresh render. It needs `rsvg-convert` and `iconutil`, and +when `iconutil` is missing it now says the `.icns` was not compared and fails, rather than +reporting a pass over a file it never looked at. + +That check does not run in CI, and claiming otherwise would be the easy lie here. The renderer is +not pinned, so two machines with different librsvg builds produce different bytes with nothing +wrong; asserting byte identity on a hosted runner would be asserting the runner's renderer +version. What CI runs instead is `tests/ci-workflows/build-desktop-icon-set.test.ts`, which needs +no renderer at all and reads its expectations out of the generator: every declared size committed +at exactly that size, the `.ico` directory carrying exactly the packed sizes with each payload a +real PNG of its declared dimension, the `.icns` walking cleanly end to end with one image member +per declared entry, and nothing hand-added beside the generated set. It was driven red on a +resized raster and on a stray file before being trusted. + +So the split is: shape is enforced everywhere, byte identity is enforced wherever the toolchain +exists. + +## Files + +- `desktop/src-tauri/icons/icon.svg` — new, the single source. +- `desktop/scripts/generate-icons.ts` — new, renderer and `--check` verifier. +- `desktop/package.json` — `icons` and `icons:check` scripts. +- Seventeen regenerated raster artifacts under `desktop/src-tauri/icons/`. +- `tests/ci-workflows/build-desktop-icon-set.test.ts` — new, the renderer-free structural guard, + registered in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## Acceptance + +`bun run icons:check` passes on the committed tree over all seventeen artifacts, +`build-desktop-icon-set.test.ts` passes and has been shown to fail on a wrong-sized raster and on +a stray file, `bun run build:local` produces a bundle whose `Contents/Resources/icon.icns` is the +generated one, and the widget verdict is an observation of the gallery rather than an inference +from registration. diff --git a/scripts/privacy-scan.ts b/scripts/privacy-scan.ts index 5d6e288b1e4..cedb2360424 100644 --- a/scripts/privacy-scan.ts +++ b/scripts/privacy-scan.ts @@ -110,6 +110,13 @@ function isAllowedEmail(file: string, email: string): boolean { } // URL-userinfo fixtures (https://user:pw@host/...) read as "pw@host" — not emails. if (file.startsWith("tests/") && email === ["pw", "chatgpt.com"].join("@")) return true; + // Retina asset names read as addresses: "128x128@2x.png" is local part "128x128", domain "2x", + // and the loose TLD rule accepts "png". The exemption is written against the asset-name grammar + // rather than against that shape, because a person's name in front of the same scale suffix is + // the same shape and is a mailbox. The part before the suffix has to be a pixel dimension, + // optionally prefixed the way an iconset member is, so a name someone could receive mail at + // does not match. + if (/^(?:[a-z]+_)?\d+x\d+@[23]x\.(?:png|jpe?g|gif|webp|tiff?)$/i.test(email)) return true; return file.startsWith("tests/") && email === "a@b.com"; } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dd578ff1571..a8d43bc015c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -303,6 +303,7 @@ "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", + "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", @@ -1124,6 +1125,7 @@ "ports.test.ts": "server", "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", + "privacy-scan-asset-names.test.ts": "ci-workflows", "privacy-scan-meta-key.test.ts": "ci-workflows", "privacy-scan-ssh-endpoint.test.ts": "ci-workflows", "probe-lease.test.ts": "routing", diff --git a/tests/ci-workflows/build-desktop-icon-set.test.ts b/tests/ci-workflows/build-desktop-icon-set.test.ts new file mode 100644 index 00000000000..703356188bb --- /dev/null +++ b/tests/ci-workflows/build-desktop-icon-set.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The committed desktop icons are generated from one SVG by `desktop/scripts/generate-icons.ts`. + * That script's own `--check` compares byte for byte, which is the strongest statement available + * — and it is not available here: it needs `rsvg-convert` and `iconutil`, and the renderer is not + * pinned, so two machines with different librsvg builds disagree on bytes without anything being + * wrong. Asserting byte identity in CI would mean asserting the runner's renderer version. + * + * What CI can assert on any platform with no renderer at all is that the committed set still has + * the shape the generator declares: every size present, every raster actually that size, both + * containers carrying exactly the members the script packs, and nothing hand-added alongside. + * Every expectation below is read out of the generator, so adding a size there and forgetting to + * regenerate fails here rather than being restated in two places that can drift apart. + */ +const GENERATOR = repoPath("desktop/scripts/generate-icons.ts"); +const ICONS_DIR = repoPath("desktop/src-tauri/icons"); + +function generatorSource(): string { + return readFileSync(GENERATOR, "utf8"); +} + +function block(source: string, opening: string, closing: string): string { + const start = source.indexOf(opening); + expect(start, `${opening} is missing from generate-icons.ts`).toBeGreaterThan(-1); + const end = source.indexOf(closing, start + opening.length); + expect(end, `${opening} is not terminated in generate-icons.ts`).toBeGreaterThan(-1); + return source.slice(start + opening.length, end); +} + +function declaredPngs(source: string): Map { + const body = block(source, "const PNG_SIZES: Record = {", "};"); + const out = new Map(); + for (const m of body.matchAll(/"([^"]+)":\s*(\d+)/g)) out.set(m[1]!, Number(m[2])); + return out; +} + +function declaredIcnsMembers(source: string): Array<{ name: string; size: number }> { + const body = block(source, "const ICNS_ENTRIES: Array<{ name: string; size: number }> = [", "];"); + return [...body.matchAll(/name:\s*"([^"]+)",\s*size:\s*(\d+)/g)].map(m => ({ name: m[1]!, size: Number(m[2]) })); +} + +function declaredIcoSizes(source: string): number[] { + const body = block(source, "const ICO_SIZES = [", "]"); + return body.split(",").map(part => Number(part.trim())).filter(n => Number.isFinite(n)); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function isPng(bytes: Buffer): boolean { + return bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE); +} + +/** Width and height out of a PNG's IHDR, which is always the first chunk. */ +function pngDimensions(bytes: Buffer): { width: number; height: number } { + expect(isPng(bytes)).toBe(true); + expect(bytes.subarray(12, 16).toString("latin1")).toBe("IHDR"); + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; +} + +describe("desktop icon set", () => { + const source = generatorSource(); + const pngs = declaredPngs(source); + const icnsMembers = declaredIcnsMembers(source); + const icoSizes = declaredIcoSizes(source); + + test("the generator still declares a set worth checking", () => { + expect(pngs.size).toBeGreaterThan(10); + expect(icnsMembers.length).toBeGreaterThan(5); + expect(icoSizes.length).toBeGreaterThan(3); + }); + + test("every declared raster is committed at exactly its declared size", () => { + const wrong: string[] = []; + for (const [name, size] of pngs) { + const bytes = readFileSync(join(ICONS_DIR, name)); + const { width, height } = pngDimensions(bytes); + if (width !== size || height !== size) wrong.push(`${name}: ${width}x${height} != ${size}`); + } + expect(wrong).toEqual([]); + }); + + test("icon.ico carries exactly the sizes the generator packs, each a real PNG of that size", () => { + const ico = readFileSync(join(ICONS_DIR, "icon.ico")); + expect(ico.readUInt16LE(0)).toBe(0); + expect(ico.readUInt16LE(2)).toBe(1); + expect(ico.readUInt16LE(4)).toBe(icoSizes.length); + + const seen: number[] = []; + for (let i = 0; i < icoSizes.length; i += 1) { + const at = 6 + i * 16; + const declared = icoSizes[i]!; + // ICO spells 256 as 0 in a single byte, which is the one field width the format never grew. + expect(ico.readUInt8(at)).toBe(declared >= 256 ? 0 : declared); + expect(ico.readUInt8(at + 1)).toBe(declared >= 256 ? 0 : declared); + const length = ico.readUInt32LE(at + 8); + const offset = ico.readUInt32LE(at + 12); + expect(offset + length).toBeLessThanOrEqual(ico.length); + const { width, height } = pngDimensions(ico.subarray(offset, offset + length)); + expect(width).toBe(declared); + expect(height).toBe(declared); + seen.push(width); + } + expect(seen).toEqual(icoSizes); + }); + + test("icon.icns is well formed and its members are the declared sizes", () => { + const icns = readFileSync(join(ICONS_DIR, "icon.icns")); + expect(icns.subarray(0, 4).toString("latin1")).toBe("icns"); + // A truncated or concatenated icns still opens with the magic; the declared length is what + // says the file is the one the tool wrote. + expect(icns.readUInt32BE(4)).toBe(icns.length); + + const types: string[] = []; + const payloads: Buffer[] = []; + let at = 8; + while (at < icns.length) { + const type = icns.subarray(at, at + 4).toString("latin1"); + const length = icns.readUInt32BE(at + 4); + expect(length).toBeGreaterThanOrEqual(8); + expect(at + length).toBeLessThanOrEqual(icns.length); + types.push(type); + payloads.push(icns.subarray(at + 8, at + length)); + at += length; + } + // The walk has to land exactly on the end, or some member lied about its length. + expect(at).toBe(icns.length); + // 'TOC ' and 'info' are bookkeeping the tool adds; the rest are the images. + const images = payloads.filter((_, i) => types[i] !== "TOC " && types[i] !== "info"); + expect(images.length).toBe(icnsMembers.length); + + // Counting members is not enough: ten duplicates of one size would count the same as the ten + // the generator declares. The larger members are PNG and carry their dimensions, so read them + // and check they are sizes the generator actually asks for. The smallest two are ARGB, which + // has no dimension in its payload, so they are counted rather than measured - and that + // accounting is what bounds how many declared sizes may be absent from the PNG members. + const png = images.filter(isPng); + const argb = images.filter(payload => payload.subarray(0, 4).toString("latin1") === "ARGB"); + expect(png.length + argb.length).toBe(images.length); + + // Consume the declared sizes one member at a time rather than comparing sets. A set would + // accept ten copies of one declared size; matching multiplicities is what makes a duplicated + // or substituted member fail, which is the realistic way this file goes wrong. + const unaccounted = icnsMembers.map(member => member.size); + for (const payload of png) { + const { width, height } = pngDimensions(payload); + expect(width).toBe(height); + const at = unaccounted.indexOf(width); + expect(at, `icon.icns carries more ${width}px members than the generator declares`).toBeGreaterThan(-1); + unaccounted.splice(at, 1); + } + // Whatever is left has to be exactly the members ARGB carries, which store no dimension. + expect(unaccounted.length).toBe(argb.length); + }); + + test("nothing is hand-added beside the generated set", () => { + const generated = new Set([...pngs.keys(), "icon.ico", "icon.icns", "icon.svg"]); + const stray = readdirSync(ICONS_DIR) + .filter(name => statSync(join(ICONS_DIR, name)).isFile()) + .filter(name => !generated.has(name)) + .sort(); + expect(stray).toEqual([]); + }); +}); diff --git a/tests/ci-workflows/privacy-scan-asset-names.test.ts b/tests/ci-workflows/privacy-scan-asset-names.test.ts new file mode 100644 index 00000000000..3dc98fd40b3 --- /dev/null +++ b/tests/ci-workflows/privacy-scan-asset-names.test.ts @@ -0,0 +1,45 @@ +/** + * The scanner's address pattern reads a Retina asset name as an email. + * + * `128x128@2x.png` is local part `128x128`, domain `2x`, and the deliberately loose TLD rule + * accepts `png`, so listing the desktop icon set failed the gate. The exemption that fixes it is + * one character away from a hole: a person's name in front of the same `@2x.png` suffix has the + * identical shape and is a mailbox, so the rule is written against the asset-name grammar — a + * pixel dimension, optionally prefixed the way an iconset member is — rather than against the + * shape. + * + * This exercises the real `scanText`. A test that restated the regex would keep passing after the + * exemption was widened, which is the only way this can go wrong. + */ +import { describe, expect, test } from "bun:test"; +import { scanText } from "../../scripts/privacy-scan"; + +/** Assembled at runtime so this file carries no bare address of its own. */ +const mailbox = (local: string, domain: string): string => [local, domain].join("@"); + +describe("privacy scan: Retina asset names", () => { + test("the icon set the generator declares does not read as addresses", () => { + for (const name of ["128x128@2x.png", "icon_16x16@2x.png", "icon_512x512@2x.png"]) { + expect(scanText("desktop/scripts/generate-icons.ts", `"${name}": 256,`) + .filter(finding => finding.kind === "email")).toEqual([]); + } + }); + + test("a mailbox wearing the same suffix is still a finding", () => { + for (const local of ["alice", "j.doe", "support"]) { + const line = `contact ${mailbox(local, "2x.png")}`; + expect(scanText("src/example.ts", line).some(finding => finding.kind === "email")).toBe(true); + } + }); + + test("the exemption does not extend past the scale suffix and a raster extension", () => { + const cases = [ + mailbox("128x128", "2x.com"), + mailbox("128x128", "4x.png"), + mailbox("128x128", "2x.example.com"), + ]; + for (const line of cases) { + expect(scanText("src/example.ts", line).some(finding => finding.kind === "email")).toBe(true); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e1e266abf4c..53fc4cbab3d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -134,6 +134,7 @@ "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", + "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", @@ -950,6 +951,7 @@ "ports.test.ts": "server", "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", + "privacy-scan-asset-names.test.ts": "ci-workflows", "privacy-scan-meta-key.test.ts": "ci-workflows", "privacy-scan-ssh-endpoint.test.ts": "ci-workflows", "probe-lease.test.ts": "routing", From 91380c7405b08cf90136df7b8a5960654498b9c1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 00:22:47 +0900 Subject: [PATCH 33/87] ci(desktop): assert the executable the app bundle declares (#5351) * ci(desktop): assert the executable the app bundle declares The macos widget + bundle job's Verify step asserted Contents/MacOS/OpenCodex, but Tauri renames the main binary only when mainBinaryName is set. desktop/src-tauri/tauri.conf.json does not set it, so the bundled executable keeps the Cargo bin name opencodex-desktop and that assertion could never pass. The step had never run before: every earlier attempt died in the build ahead of it on the missing updater signing key, which #5338 has now separated out. With the build green the Verify step ran for the first time and failed on its first line with no output, because test is silent. Read CFBundleExecutable from the bundle's own Info.plist rather than restating the name, so the check follows the config instead of drifting from it, and reject an empty value so a missing key cannot pass by testing a directory. The other three assertions are unchanged. * docs(devlog): record the two-layer widget job failure --- .github/workflows/ci.yml | 8 ++- .../020_r2_desktop_ci.md | 54 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 098a0e75a24..f10daa3fae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1205,7 +1205,13 @@ jobs: - name: Verify WidgetKit appex and desktop app run: | app=desktop/src-tauri/target/release/bundle/macos/OpenCodex.app - test -x "$app/Contents/MacOS/OpenCodex" + # Tauri renames the main binary only when `mainBinaryName` is set, and this config + # does not set it, so the bundled executable keeps the Cargo bin name rather than + # the product name. Read the name the bundle itself declares instead of restating + # it here, so this check follows the config instead of drifting from it. + executable="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist")" + test -n "$executable" + test -x "$app/Contents/MacOS/$executable" test -x "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" test -x "$app/Contents/MacOS/ocx" codesign -dv "$app/Contents/PlugIns/OpenCodexWidget.appex" diff --git a/devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md b/devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md new file mode 100644 index 00000000000..47c658ef83e --- /dev/null +++ b/devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md @@ -0,0 +1,54 @@ +# R2 — the `macos widget + bundle` failure on `dev` + +Status: the job had two independent defects stacked on top of each other. The second was +invisible until the first was fixed, because it lived in a step that had never once executed. + +## First layer: the verification build demanded the release key + +`bundle.createUpdaterArtifacts` is on and the updater public key is committed, so `tauri build` +concluded it had to emit a signed update artifact and stopped with `A public key has been found, +but no private key`. On macOS this bites even with `--bundles app`, because the macOS updater +artifact is derived from the `.app` itself. + +#5338 scoped the opt-out to the verification build with a `--config` override and left +`tauri.conf.json` alone, so release signing stays in `release.yml` where the secret lives. +`BundleConfig` carries `deny_unknown_fields`, so a misspelled override key fails the build +rather than silently reverting to signing — the override cannot rot into a no-op. + +## Second layer: the Verify step asserted a filename that never existed + +With the build green the Verify step ran for the first time and failed on its first line, +`test -x "$app/Contents/MacOS/OpenCodex"`, printing nothing because `test` is silent. + +Tauri renames the main binary only when `mainBinaryName` is set (`tauri-cli` +`src/interface/mod.rs`, with `rename_app` in `src/interface/rust/desktop.rs` a no-op +otherwise). This config does not set it, so the bundled executable keeps the Cargo bin name +`opencodex-desktop`. The job log had said so all along: `Built application at: +.../target/release/opencodex-desktop`. + +The fix reads `CFBundleExecutable` from the bundle's own `Info.plist`. `tauri-bundler` +`create_info_plist` writes that key from the same `main_binary_name()` that +`copy_binaries_to_bundle` uses for the filename, so the plist and the file on disk cannot +disagree. An empty value is rejected so a missing key cannot pass by testing the `MacOS` +directory. + +The other three assertions were checked against the same source and were already correct: +`Settings::copy_binaries` strips the `-` suffix so the sidecar lands as +`Contents/MacOS/ocx`, and `copy_custom_files_to_bundle` resolves `bundle.macOS.files` +relative to `Contents` and errors when the source is missing, so the appex is present with its +executable bit intact. + +## What this leaves open + +CI no longer exercises updater bundling at all. A regression there surfaces only during a +release. Two release-time backstops contain it — `collect-release-assets.ts` throws when the +macOS `app.tar.gz` is missing, and `updater-manifest.ts --require-all` refuses a partially +signed `latest.json` — and both are covered by `tests/ci-workflows/release-desktop-scripts.test.ts`. +What nothing covers is `tauri.conf.json` itself: no test reads it, so flipping +`createUpdaterArtifacts` off or mangling the `plugins.updater` block stays green everywhere +until a release runs. A static contract test over that file is the cheap follow-up. + +The macOS updater filename is also restated by hand in four places — +`collect-release-assets.ts`, `updater-manifest.ts`, the `release.yml` matrix, and +`structure/desktop-shell.md` — with nothing deriving one from another. The same contract test +should tie them together. From 7fead8d10b43372876a4bc91afd2c4f688e2ecb5 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 00:47:09 +0900 Subject: [PATCH 34/87] fix(desktop): give the widget an entry point and sign it at release time (#5339) * fix(desktop): give the widget an entry point and sign it at release time The WidgetKit extension installed, registered with pluginkit, and was never offered in the gallery. Signing looked like the cause and was not: the released build is signed and notarized and the widget is missing there too. app/Package.swift forced the executable's entry to _NSExtensionMain and main.swift was a comment, so nothing referenced OpenCodexWidgetBundle and no code handed it to the extension host. The shipped binary shows it: LC_MAIN pointing at _NSExtensionMain, SnapshotProvider present and the bundle absent. pluginkit registers from the Info.plist, which is complete, so registration succeeded; NSExtensionMain then looked for an NSExtensionPrincipalClass that a SwiftUI widget does not declare, because Xcode's @main on the WidgetBundle is what connects it instead. Nothing errored and the gallery had nothing to offer. main.swift now calls OpenCodexWidgetBundle.main(), which is what @main expands to, and the override is gone. Separately, release.yml ran build-widget.sh with no env block while MACOS_SIGN_IDENTITY was set one step later on the Tauri build, which never reads it, so the script took its ad-hoc branch on every release. The bundler does not re-sign anything under PlugIns - its nested-code walker knows .framework, .xpc and .app, not .appex - so an ad-hoc extension with no team identifier shipped inside a Developer ID host. The certificate is now imported before the widget build and the keychain deleted in an always() step, the widget build receives the identity, build-widget.sh adds --options runtime alongside --timestamp, and a following step asserts strict verification, the team identifier, the runtime flag and a timestamp instead of printing the signature. tests/clients/desktop-widget-entry.test.ts holds the entry point and was driven red by reinstating the linker override. The signing half needs maintainer credentials and a clean install to prove; that is recorded as outstanding rather than claimed. * feat(desktop): turn Start at Login on once, the first time an installation runs A menu bar app that is not running has no menu bar item, so leaving Start at Login off by default meant an installed app was simply absent after the next reboot, with nothing on screen to explain why. That is not a neutral default for an app whose main surface is the menu bar. first_run::apply_start_at_login_default runs once per installation, keyed on a marker in the app config directory. The marker is written before the login item is touched and is never removed, so a user who turns the setting back off keeps it off: the next launch sees the marker and does nothing. Writing afterwards instead would let a failed enable retry on every launch and eventually flip the setting back on under someone who had deliberately turned it off. Every failure is silent, because being unable to register a login item is not a reason to stop the app from starting. It runs before tray::install so the tray's Start at Login checkbox reads the state this leaves behind. The behaviour is not macOS-gated: the autostart plugin implements Linux autostart entries and the current-user Windows Run registration too. tests/clients/desktop-start-at-login-default.test.ts reads the ordering out of the source, because that ordering is the entire contract and is invisible from behaviour alone. * docs(devlog): record the verified widget entry and login-item evidence * fix(desktop): declare the widget's platform and display name Every widget macOS ships declares CFBundleSupportedPlatforms and CFBundleDisplayName. Ours declared neither, because Xcode writes both and a SwiftPM-assembled appex has no build system to write them. An extension bundle that does not say which platform it supports gives the system no reason to consider it on this one. Checked against the widgets on a macOS 27 install: Shortcuts, Tips and Reminders all carry CFBundleSupportedPlatforms = [MacOSX], and the two this bundle lacked are the only structural Info.plist differences between them and this one. * fix(desktop): give the widget both halves of an Xcode app-extension entry A widget extension needs @main on the WidgetBundle and the _NSExtensionMain linker entry, and either one alone produces a widget that is never offered in the gallery. Without @main nothing references the bundle, the linker drops it, and the extension still registers with pluginkit because the Info.plist alone is enough - so the gallery has no configuration to offer and nothing anywhere reports a problem. That is what shipped. Without the entry override the Swift main runs instead of the extension host's bootstrap and ExtensionFoundation traps in _EXRunningExtension._shared. Measured on a real install: EXC_BREAKPOINT on every launch, one crash report per attempt, and chronod logging "query failed - will try lazy reload later" while the gallery stayed empty. With both in place the crash reports stop and chronod processes the extension normally. The deployment target moves to macOS 14, which drops the now-redundant per-declaration availability guards and puts the binary's minos at 14.0, matching the working widgets on the machine this was measured on. Also recorded: the app-sandbox entitlement is not optional. Removing it does not fail at launch - pkd refuses to register the bundle at all, saying "plug-ins must be sandboxed", which is why the host writes its snapshot into the extension's own container. * ci(release): require every Mach-O in the bundle to carry the release identity Signing the extension by name is not enough. A bundler signs what it placed and nothing else, and the binaries that get missed in practice are the ones with no extension to filter on - so a check that names paths will keep passing while an unsigned executable rides along inside the bundle and notarization rejects the whole submission. This walks the built app, identifies executables by their Mach-O magic bytes rather than by path or suffix, and fails the job naming any file that does not carry the configured team identifier. Without a configured team it says so and skips, so a fork still builds and still cannot pretend to be signed. * docs(devlog): plan the stack landing and record the two dev repairs it carries * fix(release): repair the artifact upload and make the signing checks mean something Review caught four defects in the previous revision of this workflow change, one of them mine and serious. retention-days had been displaced out of the desktop upload action and into the cleanup step's shell body, where it is not a setting but a command that does not exist. Every macOS release job would have failed with "retention-days:: command not found", and the artifact would have taken the default retention on the way there. It is back in the action's with block. The keychain path is now recorded before the keychain is created, so a failure part way through still leaves the cleanup step something to delete, and the decoded certificate is removed by a trap rather than by a line a failure can skip past. The bundle-wide Mach-O sweep ran before Tauri produced a bundle, so it inspected nothing and passed. It now runs after the assets are collected and fails when it finds no app bundle or no Mach-O at all - a sweep that examined nothing is exactly the outcome it exists to prevent. All three signing checks were fail-open: with the secrets absent they printed a note and exited zero, while the unsigned artifact was still uploaded and attached. They now refuse a non-dry-run release outright and keep the permissive path only for a local or dry run. The step comment also claimed prior releases had shipped an ad-hoc extension inside a notarized host. No release has published a macOS application, so the comment says what is true instead: a defect that had not yet reached anyone. * docs(structure): record the first-run login item and the widget's two-part entry structure/ ownership obliges the doc for an area to move with the source, and this branch changed two things the desktop-shell note did not describe. first_run.rs turns Start at Login on once per installation, with the marker written before the login item so a user who turns it back off keeps it off. The widget needs @main on the bundle and the _NSExtensionMain linker entry together, and the sandbox entitlement is mandatory because pkd refuses to register an unsandboxed plug-in at all - which is the reason the shell writes its snapshot into the extension's container rather than anywhere more obvious. * fix(release): check the notarization credentials as a set and widen the Mach-O sweep Two holes left by the previous revision. Only the certificate and the team id were guarded, so a real release missing APPLE_ID or APPLE_PASSWORD still ran: the Tauri CLI skips notarization without failing when the notary credentials are absent, and the unnotarized artifact is uploaded and attached exactly as a good one would be. The five credentials are now checked together, and a non-dry-run release stops with the missing names rather than shipping something that looks finished. The bundle sweep recognised four of the eight Mach-O leading words, so a fat 64-bit or big-endian binary was skipped in silence while the other files kept the inspected-something counter healthy. All eight are listed now. The public install guidance is left alone on purpose, and the landing note says why: those pages describe an artifact that does not exist yet, and they should move with the first notarized release rather than ahead of it. * ci(desktop): assert that the widget bundle is linked into the extension The executable-name half of this landed separately as #5351, in a better form: it reads CFBundleExecutable from the bundle rather than restating the name, so it follows the config instead of drifting from it. What remains here is the assertion that has no equivalent. A widget extension with no widget in it is indistinguishable from a working one by every other check in this job: the appex builds, the signature verifies, pluginkit registers it, and the gallery is simply empty. That is what shipped, and it shipped silently. Reading the WidgetBundle symbol out of the binary is the only place in the build where its absence is visible. * fix(desktop): build the widget in extension-only mode Xcode sets APPLICATION_EXTENSION_API_ONLY on an app-extension target, and SwiftPM has no such target, so the flag has to be passed by hand. The two projects that have a SwiftPM-built widget extension working both do exactly this alongside the _NSExtensionMain linker entry, and the public report of the ExtensionFoundation crash this branch hit traces it to precisely the setup SwiftPM cannot express. Verified on a real install after the change: the extension registers, chronod captures its descriptors, and no crash report is produced. The devlog records what the same report settles - ad-hoc signing does not block the gallery, App Groups do not work ad-hoc so writing into the extension's own container is the documented fallback, and the host and extension CFBundleVersion must match, which they do. * fix(tests): bound the release injection guard at the job it is reading The guard splits the workflow on step names and scans from each "run: |" to the end of the block, which for the last step of a job runs on into the next job's header. Adding a cleanup step at the end of package-desktop made it read attach-release's job-level "if: inputs.dry-run != true" as shell interpolation inside the step above it - a condition, not a script, and not reachable by injection. Each block is now cut at the first line that dedents to job level, which is where the step's script actually ends. The guard still fails on a real interpolation: driven red by putting an inputs expression inside a run block, and green again once removed. * test(clients): hold the extension-only build flag the same way as the linker entry -application-extension is load-bearing and nothing asserted it: the existing nm check proves the WidgetBundle was linked, which stays true with the flag removed, and the runtime crash it prevents leaves the build, the signature and the pluginkit registration all looking fine. It sits beside the _NSExtensionMain assertion because the two are one contract - the projects that have a SwiftPM widget extension working supply both. structure/desktop-shell.md now records all three requirements together and says plainly that nothing observable distinguishes a broken one from a working widget. Driven red by deleting the flag. * docs(devlog): record that the widget now appears in the gallery * docs(devlog): correct the landing note for the three repairs that landed elsewhere --- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 137 +++++++++++ app/Package.swift | 29 ++- app/Sources/OpenCodexWidget/Provider.swift | 2 - app/Sources/OpenCodexWidget/Views.swift | 4 +- app/Sources/OpenCodexWidget/main.swift | 2 - app/Widget-Info.plist | 9 + desktop/scripts/build-widget.sh | 4 +- desktop/src-tauri/src/first_run.rs | 41 ++++ desktop/src-tauri/src/lib.rs | 4 + .../040_widget_never_offered.md | 215 ++++++++++++++++++ .../050_landing.md | 66 ++++++ scripts/test-layout/layout.json | 2 + structure/desktop-shell.md | 21 ++ tests/ci-workflows/ci-workflows.test.ts | 8 +- .../desktop-start-at-login-default.test.ts | 62 +++++ tests/clients/desktop-widget-entry.test.ts | 62 +++++ tests/fixtures/test-layout-expected.json | 2 + 18 files changed, 664 insertions(+), 12 deletions(-) delete mode 100644 app/Sources/OpenCodexWidget/main.swift create mode 100644 desktop/src-tauri/src/first_run.rs create mode 100644 devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md create mode 100644 devlog/_plan/260920_desktop_app_stabilization/050_landing.md create mode 100644 tests/clients/desktop-start-at-login-default.test.ts create mode 100644 tests/clients/desktop-widget-entry.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f10daa3fae6..c7d1c5de8f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1215,6 +1215,12 @@ jobs: test -x "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" test -x "$app/Contents/MacOS/ocx" codesign -dv "$app/Contents/PlugIns/OpenCodexWidget.appex" + # The widget is only offered in the gallery when its bundle is actually linked in, and + # nothing else here would notice its absence: the appex builds, signs and registers + # exactly the same way with the WidgetBundle dropped by the linker. + nm -a "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" \ + | grep -q "OpenCodexWidget0abC6BundleV" \ + || { echo "::error::the widget bundle is not linked into the extension"; exit 1; } desktop-shell: name: desktop shell diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd63e98a51e..5a10187cb5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,10 +236,89 @@ jobs: if: runner.os != 'macOS' run: bun desktop/scripts/prepare-sidecar.ts --target ${{ matrix.sidecar-targets }} + # The signing certificate has to be in a keychain before the widget is signed, and the + # Tauri build step creates its own keychain only when it runs — which is after this. Until + # this step existed, build-widget.sh saw no MACOS_SIGN_IDENTITY and took its unsigned + # branch, and the bundler does not re-sign anything under PlugIns, so the extension would + # have gone out ad-hoc inside a Developer ID host. No release has published a macOS + # application yet, so this is a defect that had not reached anyone rather than one that had. + - name: Import the release signing certificate + if: runner.os == 'macOS' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + # Checked as a set, because a partial set is the dangerous case: the Tauri CLI skips + # notarization without failing when the notary credentials are missing, and the + # unnotarized artifact is uploaded and attached exactly as a good one would be. + missing="" + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release needs the full signing and notarization credential set." + echo "::error::Missing:$missing" + exit 1 + fi + echo "Signing credentials are incomplete, so this build stays ad-hoc signed:$missing" + echo "It is usable for local validation and is not a release asset." + exit 0 + fi + keychain="$RUNNER_TEMP/opencodex-signing.keychain-db" + # Recorded before anything is created, so the cleanup step can still find a keychain + # that a failure left half-built. + echo "OPENCODEX_SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" + keychain_password="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + certificate="$RUNNER_TEMP/opencodex-signing.p12" + # The decoded certificate must not outlive this step even when a later command fails. + trap 'shred -u "$certificate" 2>/dev/null || rm -Pf "$certificate" 2>/dev/null || true' EXIT + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$certificate" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$certificate" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$keychain_password" "$keychain" > /dev/null + # shellcheck disable=SC2046 # the keychain list is intentionally word-split into arguments + security list-keychain -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + - name: Build WidgetKit extension if: runner.os == 'macOS' + env: + MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: bash desktop/scripts/build-widget.sh + - name: Verify the extension carries the release signature + if: runner.os == 'macOS' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + appex=desktop/src-tauri/widget/OpenCodexWidget.appex + if [ -z "${APPLE_TEAM_ID}" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release cannot assert its own signature without APPLE_TEAM_ID." + exit 1 + fi + echo "No team configured; skipping the signature assertion for this non-release build." + exit 0 + fi + codesign --verify --strict --deep "$appex" + description="$(codesign -dvvv "$appex" 2>&1)" + echo "$description" + echo "$description" | grep -q "TeamIdentifier=$APPLE_TEAM_ID" + echo "$description" | grep -q "flags=.*runtime" + echo "$description" | grep -q "Timestamp=" + # Release signing is intentionally secret-gated. Developer ID, notarization, # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. @@ -267,6 +346,55 @@ jobs: --target "$DESKTOP_TARGET" \ --out dist/release + # After the bundle exists, not before: a sweep that runs first passes by finding nothing. + - name: Verify every Mach-O in the bundle carries the release identity + if: runner.os == 'macOS' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + if [ -z "${APPLE_TEAM_ID}" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release cannot verify its bundle without APPLE_TEAM_ID." + exit 1 + fi + echo "No team configured; skipping the bundle-wide assertion for this local build." + exit 0 + fi + # Executables are found by their magic bytes rather than by path or extension. A bundler + # signs what it placed; anything copied in afterwards is invisible to it, and the + # binaries that get missed are the ones with no extension to filter on. + apps=0 + machos=0 + bad=0 + while IFS= read -r app; do + apps=$((apps + 1)) + echo "checking $app" + while IFS= read -r -d '' file; do + # All eight Mach-O leading words: thin and fat, 32- and 64-bit, both byte orders. + # A list that covers only the common ones skips the rest in silence while the + # non-zero counter below still reports a healthy sweep. + case "$(head -c 4 "$file" | xxd -p)" in + cefaedfe|cffaedfe|feedface|feedfacf) ;; + cafebabe|bebafeca|cafebabf|bfbafeca) ;; + *) continue ;; + esac + machos=$((machos + 1)) + if ! codesign -dvvv "$file" 2>&1 | grep -q "TeamIdentifier=$APPLE_TEAM_ID"; then + echo "::error::$file is not signed with the release identity" + bad=1 + fi + done < <(find "$app" -type f -print0) + done < <(find desktop/src-tauri/target -maxdepth 6 -type d -name '*.app') + echo "inspected $machos Mach-O files across $apps app bundles" + # A sweep that inspected nothing is the failure mode this step exists to prevent. + if [ "$apps" -eq 0 ] || [ "$machos" -eq 0 ]; then + echo "::error::found $apps app bundles and $machos Mach-O files; the sweep inspected nothing" + exit 1 + fi + exit "$bad" + - name: Upload desktop release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -275,6 +403,15 @@ jobs: if-no-files-found: error retention-days: 7 + # always(), because a keychain holding the release identity must not survive a failed job + # on a runner image that could be reused. + - name: Remove the signing keychain + if: always() && runner.os == 'macOS' + run: | + if [ -n "${OPENCODEX_SIGNING_KEYCHAIN:-}" ] && [ -f "${OPENCODEX_SIGNING_KEYCHAIN}" ]; then + security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" + fi + attach-release: runs-on: ubuntu-latest needs: [publish, package-standalone, package-desktop] diff --git a/app/Package.swift b/app/Package.swift index 0e14e3bd5b8..a741d5bfd12 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "OpenCodexWidget", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], products: [ .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), @@ -14,9 +14,32 @@ let package = Package( name: "OpenCodexWidget", dependencies: ["MenuBarCore"], path: "Sources/OpenCodexWidget", + swiftSettings: [ + // Xcode sets APPLICATION_EXTENSION_API_ONLY on an app-extension target, and the + // two projects that have this working from SwiftPM pass its compiler spelling by + // hand. It restricts the target to the extension-safe API surface, which is the + // contract the extension host assumes it was built against. + .unsafeFlags(["-application-extension"]), + ], linkerSettings: [ - // Widget extensions must enter through NSExtensionMain or chronod tears down - // the process before the WidgetBundle connects. + // A widget extension needs both halves of what Xcode does for an app-extension + // target, and each half is useless alone. This flag is one of them; `@main` on + // OpenCodexWidgetBundle is the other. + // + // With the entry override and no `@main`, nothing references the WidgetBundle, the + // linker drops it, and the extension registers with pluginkit — the Info.plist is + // enough for that — while the gallery has no configuration to offer. That is what + // shipped, and it failed silently. + // + // With `@main` and no entry override, the Swift main runs instead of + // NSExtensionMain, and ExtensionFoundation traps inside + // _EXRunningExtension._shared while bootstrapping. Measured: EXC_BREAKPOINT on + // every launch, chronod logging "query failed - will try lazy reload later", and + // a crash report per attempt. + // + // Both together is the shape that works and the shape Xcode produces: the entry + // is NSExtensionMain, and the bundle stays in the binary because `@main` refers + // to it. .linkedFramework("Foundation"), .unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), ] diff --git a/app/Sources/OpenCodexWidget/Provider.swift b/app/Sources/OpenCodexWidget/Provider.swift index 55d983bf5cf..e23d55f6042 100644 --- a/app/Sources/OpenCodexWidget/Provider.swift +++ b/app/Sources/OpenCodexWidget/Provider.swift @@ -2,7 +2,6 @@ import Foundation import WidgetKit import MenuBarCore -@available(macOS 14, *) public struct SnapshotEntry: TimelineEntry { public let date: Date public let snapshot: WidgetSnapshot? @@ -10,7 +9,6 @@ public struct SnapshotEntry: TimelineEntry { public let stale: Bool } -@available(macOS 14, *) public struct SnapshotProvider: TimelineProvider { private let reader = SnapshotReader() diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift index edc91171f44..7b8d6fc346c 100644 --- a/app/Sources/OpenCodexWidget/Views.swift +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -2,7 +2,6 @@ import SwiftUI import WidgetKit import MenuBarCore -@available(macOS 14, *) struct OpenCodexWidgetView: View { let entry: SnapshotEntry @Environment(\.widgetFamily) private var family @@ -301,14 +300,13 @@ struct OpenCodexWidgetView: View { } } -@available(macOS 14, *) +@main struct OpenCodexWidgetBundle: WidgetBundle { var body: some Widget { OpenCodexWidget() } } -@available(macOS 14, *) struct OpenCodexWidget: Widget { let kind = "OpenCodexWidget" diff --git a/app/Sources/OpenCodexWidget/main.swift b/app/Sources/OpenCodexWidget/main.swift deleted file mode 100644 index 7eeda563867..00000000000 --- a/app/Sources/OpenCodexWidget/main.swift +++ /dev/null @@ -1,2 +0,0 @@ -// WidgetKit enters through _NSExtensionMain; this file keeps the executable target's -// source directory populated without adding a competing Swift-generated main. diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist index 568e5fce750..df499104290 100644 --- a/app/Widget-Info.plist +++ b/app/Widget-Info.plist @@ -7,6 +7,15 @@ CFBundleIdentifiercom.opencodex.desktop.widget CFBundleInfoDictionaryVersion6.0 CFBundleNameOpenCodex + CFBundleDisplayNameOpenCodex + + CFBundleSupportedPlatforms + MacOSX CFBundlePackageTypeXPC! CFBundleShortVersionString0.0.0 CFBundleVersion0.0.0 diff --git a/desktop/scripts/build-widget.sh b/desktop/scripts/build-widget.sh index 59827dcea2a..fb24526e477 100755 --- a/desktop/scripts/build-widget.sh +++ b/desktop/scripts/build-widget.sh @@ -66,8 +66,10 @@ plutil -replace CFBundleShortVersionString -string "$version_core" "$output_dir/ plutil -replace CFBundleVersion -string "$version_core" "$output_dir/Contents/Info.plist" if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + # Hardened runtime and a secure timestamp are both required for notarized Developer ID + # software, and an extension that lacks either fails notarization with the host around it. codesign --force --sign "$MACOS_SIGN_IDENTITY" --entitlements "$package_dir/Widget.entitlements" \ - --timestamp "$output_dir" + --options runtime --timestamp "$output_dir" else codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ --timestamp=none "$output_dir" diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs new file mode 100644 index 00000000000..e6a0417ed97 --- /dev/null +++ b/desktop/src-tauri/src/first_run.rs @@ -0,0 +1,41 @@ +use std::fs; +use tauri::{AppHandle, Manager}; +use tauri_plugin_autostart::ManagerExt; + +/// Marker file recording that the one-time Start at Login default has already been applied. +const MARKER: &str = "start-at-login-claimed"; + +/// Turn Start at Login on once, the first time this installation runs. +/// +/// A menu bar app that is not running has no menu bar item. Leaving autostart off by default +/// therefore means that after the next reboot an installed app is simply absent, with nothing on +/// screen to explain why — which is not a neutral default for an app whose main surface *is* the +/// menu bar. +/// +/// This runs exactly once per installation. The marker is written **before** the login item is +/// touched, and is never removed, so a user who turns Start at Login back off keeps it off: the +/// next launch sees the marker and does nothing. Writing afterwards instead would mean that a +/// failed or partial enable retries on every launch, and would eventually flip the setting back on +/// under a user who had deliberately turned it off in between. +/// +/// Every failure is silent on purpose. Not being able to write a marker or register a login item +/// is not a reason to stop the app from starting, and the user can still toggle the menu item. +pub fn apply_start_at_login_default(app: &AppHandle) { + let Ok(dir) = app.path().app_config_dir() else { + return; + }; + let marker = dir.join(MARKER); + if marker.exists() { + return; + } + if fs::create_dir_all(&dir).is_err() { + return; + } + if fs::write(&marker, b"").is_err() { + return; + } + if app.autolaunch().is_enabled().unwrap_or(false) { + return; + } + let _ = app.autolaunch().enable(); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index aa688daca9f..5ee02473c7e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod auth; mod discovery; +mod first_run; mod formatting; mod logging; mod proxy; @@ -101,6 +102,9 @@ pub fn run() { if tauri::async_runtime::block_on(proxy.is_alive()).is_ok() { let _ = window.eval(format!("window.location.replace({dashboard:?})")); } + // Before the tray, so its Start at Login checkbox reads the state this leaves behind + // rather than the state from before first run. + first_run::apply_start_at_login_default(app.handle()); tray::install(app.handle(), proxy)?; if !cfg!(debug_assertions) { updater::start_background_checks(app.handle().clone()); diff --git a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md new file mode 100644 index 00000000000..062fbfa4d27 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md @@ -0,0 +1,215 @@ +# wp5 — the widget registered, and offered nothing + +## The symptom, and why it was not a signing problem + +The extension installs, `pluginkit` lists it beside the system widgets, and the gallery does not +show it. The obvious reading was signing: a locally built host is ad-hoc signed, so of course the +system will not adopt its extension. That reading was wrong, and following it would have produced +a signing change that fixed nothing, because the released build is signed and notarized and the +widget is missing there too. + +The actual defect is in the binary. `app/Package.swift` forced the executable's entry point: + +```swift +.unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), +``` + +and `app/Sources/OpenCodexWidget/main.swift` held nothing but a comment explaining that the entry +was handled by that flag. So `OpenCodexWidgetBundle` — which `Views.swift` defines correctly, +with a display name, a description and three supported families — was never referenced by +anything, and no code ever handed it to the extension host. + +Read off the shipped bundle: + +``` +LC_MAIN entryoff -> _NSExtensionMain +nm: SnapshotProvider present, OpenCodexWidgetBundle absent +Info.plist: NSExtensionPointIdentifier = com.apple.widgetkit-extension + NSExtensionPrincipalClass = (absent) +``` + +That combination is exactly consistent with the symptom. `pluginkit` registers from the +Info.plist, which is complete, so registration succeeds. `NSExtensionMain` then looks for an +`NSExtensionPrincipalClass`, which a SwiftUI widget does not declare because Xcode's `@main` on +the `WidgetBundle` is what connects it instead. Nothing errors. The gallery simply has no +configuration to offer. + +## The fix, and the wrong turn on the way to it + +The first attempt was to delete the linker override and call the bundle from `main.swift`. That +made the bundle's symbols appear in the binary and did not work either — it replaced a silent +failure with a loud one. Every launch died: + +``` +EXC_BREAKPOINT (SIGTRAP) + ExtensionFoundation closure #1 in ... _EXRunningExtension._shared + ExtensionFoundation MainActor.assumeIsolated + ExtensionFoundation _EXExtension.bootstrap(with:) + WidgetKit + OpenCodexWidget main +chronod: [com.opencodex.desktop::com.opencodex.desktop.widget] query failed - will try lazy + reload later +``` + +Seventeen crash reports accumulated in `~/Library/Logs/DiagnosticReports` while the gallery stayed +empty, because `chronod` asks the extension for its descriptors and the extension never survives +long enough to answer. + +**The extension needs both halves of what Xcode does, and each is useless alone.** `@main` on the +`WidgetBundle` is what keeps it in the binary; `-e _NSExtensionMain` is what makes the process +start as an extension rather than as a program. The original code had the second without the +first, this branch briefly had the first without the second, and only both together produce a +widget the system will talk to. With both in place the crash reports stop at zero and `chronod` +processes the extension normally. + +`tests/clients/desktop-widget-entry.test.ts` asserts both, plus that no `main.swift` has come back +to compete with `@main`, and that the bundle carries a widget with a display name rather than an +empty body — the same failure by a third route. + +The deployment target moved to macOS 14 at the same time, which drops the per-declaration +`@available(macOS 14, *)` guards and puts the binary's `minos` at 14.0, matching every working +widget on the machine this was measured on. + +## The sandbox is not optional + +While narrowing this down, the extension was rebuilt without `com.apple.security.app-sandbox` to +test whether the sandbox was implicated. It is required, and the system says so plainly: + +``` +pkd: Ignoring mis-configured plugin at [.../OpenCodexWidget.appex]: plug-ins must be sandboxed +``` + +An unsandboxed extension is not rejected at launch — it is never registered at all, so it vanishes +from `pluginkit` entirely. That also settles the snapshot path: the host writes into +`~/Library/Containers/com.opencodex.desktop.widget/Data/...` precisely because the extension reads +its own container, and that arrangement has to stay. + +## What the public record says about this failure + +The `_EXRunningExtension` crash is not unique to this repository, and finding the precedent +changed how much of the fix is guesswork. A forensic report on macOS 26.5 with Swift 6.3.2 +describes the same trap from the same cause — a widget extension assembled from a SwiftPM +`.executableTarget` and wrapped into an `.appex` by hand — and records that neither Info.plist +shape avoids it, because SwiftPM has no app-extension target and therefore never applies the +entry-point setup Xcode's WidgetKit template provides. That project's resolution was to stop +using SwiftPM for the extension and build a real Xcode app-extension target instead. + +Two other projects keep SwiftPM and supply the missing pieces by hand, which is the route taken +here: the linker entry (`-Xlinker -e -Xlinker _NSExtensionMain`) and the compiler's +extension-only mode (`-application-extension`, which is what Xcode spells +`APPLICATION_EXTENSION_API_ONLY`). Both are now set, and the extension launches and answers +`chronod` without a crash report. + +Three things the same record settles that were open questions here: + +- **Ad-hoc signing does not prevent gallery appearance.** Developer ID and notarization matter for + Gatekeeper, not for gallery mechanics. The containing app does have to be launched once after + installation, which is what makes the first-run behaviour in this branch load-bearing for more + than the menu bar. +- **App Groups do not work under ad-hoc signing**, and the documented fallback is exactly what + this repository already does — the host writes into the extension's own container. +- **`CFBundleVersion` must match between host and extension** or WidgetKit rejects timeline + reloads. Verified on the installed bundle: both read 2.61.0. + +If the gallery still refuses this extension after the entry point and the extension-only build, +the remaining known cause is the Xcode app-extension target itself, and that is a larger change +than this unit: it means adding an Xcode project for the widget and building it with +`xcodebuild` rather than `swift build`. + +## The signing defect underneath it + +Fixing the entry point does not make a *released* widget adoptable on someone else's machine, +because the release pipeline would not sign it. + +`.github/workflows/release.yml` ran `build-widget.sh` with no `env:` block. `MACOS_SIGN_IDENTITY` +was set one step later, on the Tauri build, which never reads it. So the script took its +`codesign --force --sign -` branch, and the bundler does not re-sign anything under `PlugIns/` — +its nested-code walker handles `.framework`, `.xpc` and `.app`, not `.appex`. + +**This has not harmed a release yet, and the reason matters.** No release has ever published a +macOS application: the last three carry no desktop assets at all, and the signing secrets did not +exist until after the most recent one was cut. `MACOS_SIGN_IDENTITY` reads a secret that was not +there, so the real-signing branch has never executed and the Developer ID path in the Tauri step +has never executed either. The bug is a mine rather than a crater — the next release is the first +one that would step on it. Saying otherwise would be inventing a history this repository does not +have. + +**Signing one path is also not enough.** A bundler that did not place a file does not sign it, and +picking binaries by file extension misses the ones that have none. The durable form of the check +is to find Mach-O files by their magic bytes and require every one of them to carry the release +identity, rather than naming the paths that are expected to exist. + +Three changes: + +- The certificate is imported into a temporary keychain in a step **before** the widget build, and + the keychain is deleted in an `always()` step so it cannot outlive a failed job. +- The widget build receives `MACOS_SIGN_IDENTITY`, and `build-widget.sh` now signs with + `--options runtime` as well as `--timestamp`, both of which notarization requires. +- A step after the widget build asserts the result rather than printing it: strict verification, + the configured team identifier, the runtime flag, and a secure timestamp. Without a configured + team it says so and skips, so a fork's build still works and still cannot pretend to be signed. + +This half cannot be proven here. It needs maintainer-held credentials, and the proof is a +notarized artifact installed on a machine that did not build it, launched once, with the gallery +then checked. That is recorded as the outstanding verification rather than claimed. + +## The menu bar had the same shape of problem + +Start at Login was purely opt-in. Nothing enabled it on first run, so an install left the user +with a menu bar item only for as long as the app happened to be running — and a menu bar app that +is not running has no menu bar item. After a reboot the app was simply absent. + +`first_run::apply_start_at_login_default` enables it once per installation, keyed on a marker in +the app config directory, and runs before `tray::install` so the tray checkbox reads the state it +leaves behind. The marker is written before the login item is touched and is never removed, so a +user who turns the setting off keeps it off. Writing afterwards would let a failed enable retry +every launch and eventually flip the setting back under someone who had deliberately disabled it. + +The marker distinguishes a fresh install from a user who opted out, but it cannot distinguish +either from an install that predates the marker. The desktop shell and the widget both landed the +same day this was written and no release tag contains them, so there is no such population; if +that changes, this needs a migration rather than a marker. + +## What was verified here + +Rebuilt, installed to `/Applications`, and launched: + +``` +LC_MAIN entryoff 5656 -> _main (was _NSExtensionMain) +nm: _$s15OpenCodexWidget0abC6BundleV4bodyQrvpQOMQ present +pluginkit: com.opencodex.desktop.widget re-registered, parent bundle resolved +~/Library/Application Support/com.opencodex.desktop/start-at-login-claimed written +~/Library/LaunchAgents/OpenCodex.plist created +``` + +So the entry point is connected and the login item is registered, both on a real install rather +than in a test double. + +## Verdict: it appears + +The gallery was opened on this machine after the fix and OpenCodex is in it, between OKX and +PASS, with all three declared families rendering real data rather than placeholders: + +``` +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemSmall +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemMedium +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemLarge + "OpenCodex — Proxy status, today's usage, and quota at a glance." +``` + +Small shows the token count for the day, medium adds requests, cost and the account quota rows, +large adds the 24-hour per-model timeline. The list icon is the mark generated from `icon.svg`. + +That settles the whole question the acceptance note left open, and it settles it the right way +round: the extension was never rejected by signing or by the sandbox. It had no widget in it, and +then it had one that could not start. Both are fixed, and the fix is a SwiftPM configuration +rather than the Xcode app-extension target the public record recommends — so the cheaper route +does work, provided all three of `@main`, the `_NSExtensionMain` entry and +`-application-extension` are present. + +## Acceptance + +The entry-point half is closed by the gallery observation above. The signing half closes when a +release build's extension reports the team identifier, the runtime flag and a timestamp, and a +clean install on a machine that did not build it offers the widget. That second half needs a real +release and is recorded as outstanding. diff --git a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md new file mode 100644 index 00000000000..4e6135b9d70 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md @@ -0,0 +1,66 @@ +# wp5 — landing the stack + +## Shape + +Four pull requests, each based on the one below it, all ultimately targeting `dev`: + +| PR | branch | what it carries | +|---|---|---| +| #5327 | `codex/260920-app-stabilization` | release profile, stale-dist report, `build:local`, the lockfile and test-layout repairs | +| #5328 | `codex/260920-claude-desktop-mode-visibility` | the first-party reachability message | +| #5329 | `codex/260920-app-icons` | one SVG source, the generator, the renderer-free CI guard | +| #5339 | `codex/260920-widget-entry` | the widget entry point, the login-item default, release signing | + +They merge bottom-up. After each one lands, the next is retargeted to `dev` and its exact head is +read again, because a squash merge rewrites the parent and the child's base disappears. + +## Two repairs in here are not ours + +`dev` was already red when this stack was cut, in two independent places, and both were fixed +here because every branch cut from `dev` inherits them. + +`tests/providers/stepfun-provider.test.ts` landed with no entry in either inventory and no regex +seed that resolves its name, so the membership oracle failed on `dev` and on everything branched +from it. Registering it under `providers` restores the gate for everyone. + +`macos widget + bundle` failed with *A public key has been found, but no private key*. The job is +an unsigned build by design, so the key is correctly absent — but the committed config sets +`bundle.createUpdaterArtifacts` and `plugins.updater.pubkey`, so `tauri build` writes the updater +archive and then refuses to finish. That half is #5338's, which turns the artifact off for that one +invocation; this stack does not duplicate it. + +Fixing the build revealed the rest of the job, which had never run. Its first assertion looked for +`Contents/MacOS/OpenCodex` — `productName` — while the bundle carries `opencodex-desktop`, the +crate name. That half landed separately as #5351, and better than the version written here: it +reads `CFBundleExecutable` out of the bundle instead of restating the name, so the check follows +the config rather than drifting from it. This stack's copy was dropped in favour of it. + +What remains here is the assertion with no equivalent: that the WidgetBundle is actually linked +into the extension. The appex builds, signs and registers identically with the bundle dropped by +the linker, so nothing else in this job would have noticed the defect that shipped. + +Three of this stack's incidental repairs turned out to be running in parallel with the +maintainer's own: the StepFun layout registration (#5335), the widget job's updater override +(#5338), and this executable assertion (#5351). Each was dropped here once the other landed. The +pattern is worth noting for the next batch — a repair found while passing through is worth +checking against open pull requests before it is written. + +## What closes this + +Each merge reads the exact head's check runs rather than a rollup, distinguishes a job the event +requested from one it skipped, and treats a missing, skipped, or cancelled job as not a pass. The +last merge is followed by reading `dev`'s own push run, because five of the eight defects found in +this unit were invisible until two changes met. + +## Deliberately not changed here + +Review asked for the public macOS install guidance to move with the release path, since +`README.md`, `guides/desktop-app.md` and `guides/macos-menu-bar.md` all tell the reader the app is +ad-hoc signed and not notarized, while this stack makes a real release refuse to run without a +Developer ID and the full notarization credential set. + +Those pages are accurate today and will stop being accurate at the next release, not at this +merge. No release has ever published a macOS application, so rewriting them now would describe an +artifact nobody can download and would leave the Gatekeeper walkthrough — still correct for a +locally built app — reading as though it were obsolete. The pages move with the first notarized +artifact, which is also when someone can check the instructions against a real download. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a8d43bc015c..d928c6dd1fd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -715,7 +715,9 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index ac1cb85b322..cb5768a0c92 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -11,6 +11,27 @@ navigates the webview to the proxy's loopback dashboard Only the bootstrap page has Tauri IPC capability; the loopback dashboard never does because `dangerousRemoteDomainIpcAccess` is not configured. +`desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, +before the tray is built so its checkbox reads the resulting state. A menu bar app +that is not running has no menu bar item, so leaving autostart off by default left an +installed app absent after a reboot. The marker in the app config directory is written +before the login item is touched and is never removed, so a user who turns the setting +off keeps it off; writing it afterwards would let a failed enable retry on every launch. +The behaviour is not macOS-only — the autostart plugin implements the Linux autostart +entry and the current-user Windows Run registration too. + +The WidgetKit extension in `app/` needs three things that Xcode's app-extension target +would supply on its own, and SwiftPM has no such target: `@main` on +`OpenCodexWidgetBundle`, the `-e _NSExtensionMain` linker entry, and +`-application-extension` — the compiler spelling of `APPLICATION_EXTENSION_API_ONLY` — all +in `app/Package.swift`. Any one missing yields a widget that never appears: without +`@main` the linker drops the bundle and the extension registers with nothing to offer, and +without the entry override ExtensionFoundation traps during bootstrap. Nothing observable +distinguishes these from a working widget, because the bundle still builds, signs and +registers. `com.apple.security.app-sandbox` is also mandatory — `pkd` refuses to register +an unsandboxed plug-in at all — which is why the shell writes its snapshot into the +extension's own container rather than a shared App Group, which ad-hoc signing cannot use. + `desktop/scripts/prepare-sidecar.ts` maps Rust target triples to the standalone Bun targets and prepares the external binary plus dashboard resources used by Tauri. Generated files under desktop/src-tauri/binaries/ and diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 8746d8539f9..6d7959c3e57 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -918,9 +918,15 @@ describe("GitHub Actions hardening", () => { // Workflow-dispatch inputs must reach shell code via env, never by direct // interpolation into run: source (script-injection hardening). + // The split alone does not bound a block: the last step of a job runs on into the next + // job's header, so a job-level `if: ${{ inputs.dry-run != true }}` — which is a condition, + // not shell — read as an injection in the step above it. Each block is cut at the first + // line that dedents to job level, which is where the step's script actually ends. const runBlocks = workflow.split(/\n {6,}- name: /).filter(block => block.includes("run: |")); for (const block of runBlocks) { - const runSource = block.slice(block.indexOf("run: |")); + const afterRun = block.slice(block.indexOf("run: |")); + const jobBoundary = afterRun.search(/\n {2}\S/); + const runSource = jobBoundary === -1 ? afterRun : afterRun.slice(0, jobBoundary); expect(runSource).not.toContain("${{ inputs."); } diff --git a/tests/clients/desktop-start-at-login-default.test.ts b/tests/clients/desktop-start-at-login-default.test.ts new file mode 100644 index 00000000000..abf3472b79c --- /dev/null +++ b/tests/clients/desktop-start-at-login-default.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * A menu bar app that is not running has no menu bar item, so leaving Start at Login off by + * default means an installed app is simply gone after a reboot. The desktop shell enables it once + * per installation. + * + * The ordering is the whole contract and it is not visible from behaviour alone, so it is read out + * of the source: the marker is written before the login item is touched, the enable is guarded by + * the current state, an existing marker returns early, and the tray is built afterwards so its + * checkbox reflects the result. Get the write order backwards and a user who turns the setting off + * has it turned back on for them on the next launch. + */ +const FIRST_RUN = repoPath("desktop/src-tauri/src/first_run.rs"); +const LIB = repoPath("desktop/src-tauri/src/lib.rs"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +describe("start at login default", () => { + const firstRun = code(FIRST_RUN); + + test("an existing marker returns before anything is changed", () => { + const early = firstRun.indexOf("marker.exists()"); + const enable = firstRun.indexOf("autolaunch().enable()"); + expect(early).toBeGreaterThan(-1); + expect(enable).toBeGreaterThan(-1); + expect(early).toBeLessThan(enable); + expect(firstRun.slice(early, enable)).toContain("return"); + }); + + test("the marker is written before the login item is registered", () => { + const write = firstRun.indexOf("fs::write(&marker"); + const enable = firstRun.indexOf("autolaunch().enable()"); + expect(write).toBeGreaterThan(-1); + expect(write).toBeLessThan(enable); + }); + + test("enabling is guarded by the current autolaunch state", () => { + const guard = firstRun.indexOf("autolaunch().is_enabled()"); + const enable = firstRun.indexOf("autolaunch().enable()"); + expect(guard).toBeGreaterThan(-1); + expect(guard).toBeLessThan(enable); + }); + + test("nothing ever deletes the marker", () => { + expect(firstRun).not.toContain("remove_file"); + expect(firstRun).not.toContain("remove_dir"); + }); + + test("it runs before the tray is installed", () => { + const lib = code(LIB); + const applied = lib.indexOf("first_run::apply_start_at_login_default"); + const tray = lib.indexOf("tray::install"); + expect(applied).toBeGreaterThan(-1); + expect(tray).toBeGreaterThan(-1); + expect(applied).toBeLessThan(tray); + }); +}); diff --git a/tests/clients/desktop-widget-entry.test.ts b/tests/clients/desktop-widget-entry.test.ts new file mode 100644 index 00000000000..6871bbd56cc --- /dev/null +++ b/tests/clients/desktop-widget-entry.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The WidgetKit extension needs two things that look unrelated, and either one alone produces a + * widget that is never offered in the gallery with nothing in the build to say so. + * + * Without `@main` on the WidgetBundle, nothing references it, the linker drops it, and the + * extension still registers with `pluginkit` because the Info.plist alone is enough. The gallery + * then has no configuration to offer. That is what shipped. + * + * Without the `_NSExtensionMain` linker entry, the Swift main runs instead of the extension host's + * bootstrap and ExtensionFoundation traps in `_EXRunningExtension._shared` — EXC_BREAKPOINT on + * every launch, one crash report per attempt, and `chronod` logging + * "query failed - will try lazy reload later". + * + * Both were measured on a real install. Neither is visible to a build that only checks the bundle + * is well formed and the signature verifies, which is why they are asserted from the source. + */ +const PACKAGE = repoPath("app/Package.swift"); +const VIEWS = repoPath("app/Sources/OpenCodexWidget/Views.swift"); + +function stripComments(source: string): string { + return source.replace(/\/\/[^\n]*/g, ""); +} + +describe("widget extension entry point", () => { + test("the linker entry is NSExtensionMain, as it is for an Xcode app-extension target", () => { + const code = stripComments(readFileSync(PACKAGE, "utf8")); + expect(code).toContain("_NSExtensionMain"); + }); + + test("the target is compiled in extension-only mode", () => { + // Xcode's app-extension target sets APPLICATION_EXTENSION_API_ONLY; SwiftPM has no such + // target, so the compiler's spelling is passed by hand. It belongs beside the linker entry + // because the two are one contract: the projects that have a SwiftPM widget extension + // working supply both, and dropping either brings back a failure that the build, the + // signature and the registration all continue to look fine through. + const code = stripComments(readFileSync(PACKAGE, "utf8")); + expect(code).toContain("-application-extension"); + }); + + test("the widget bundle is the Swift entry, so the linker keeps it", () => { + const views = readFileSync(VIEWS, "utf8"); + expect(views).toMatch(/@main\s*\n\s*struct OpenCodexWidgetBundle: WidgetBundle/); + }); + + test("there is no main.swift competing with @main", () => { + // SwiftPM refuses @main in a target that also has a main.swift, and the refusal is a build + // error rather than a silent fallback - but the file existing at all means someone moved the + // entry back out of the bundle. + expect(existsSync(repoPath("app/Sources/OpenCodexWidget/main.swift"))).toBe(false); + }); + + test("the bundle actually carries a widget", () => { + const views = readFileSync(VIEWS, "utf8"); + // A WidgetBundle with an empty body offers nothing, which is the same failure by another route. + expect(views).toMatch(/OpenCodexWidget\(\)/); + expect(views).toMatch(/configurationDisplayName/); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 53fc4cbab3d..11be2c22de7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -546,7 +546,9 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", "devin-effort-ladder.test.ts": "providers", From bf3dfd1b00011a5963ab714ce67d826a57bd8ab8 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:06:08 +0900 Subject: [PATCH 35/87] fix(transport): admit SOCKS scheme variables on both sides of the proxy decision (#5269) Review on #5264 caught the inconsistency this closes. A SOCKS URL written into a scheme-matched variable (HTTPS_PROXY=socks5://...) was accepted by schemeMatchedProxyFor as an explicit fake-IP binding, while effectiveProxyFor rejected it as unusable, so admission opened the Mihomo IPv6 gate and the transport decision kept the DNS-pinned path: the request pin-connected to a fake-IP address only the proxy can resolve. Dev before #5264 admitted and bound the same value, so the merged state was a regression in consistency, not a deliberate tightening. effectiveProxyFor now returns a SOCKS scheme-matched value as applying, the same side of the decision schemeMatchedProxyFor already took: admission binds it explicitly and the transport follows through configuredOutboundFetch's SOCKS path. Non-SOCKS unusable values keep failing closed with no ALL_PROXY fall-through. The regression drives an https target with HTTPS_PROXY=socks5://... and a fake-IP answer: admission must open, and the request must ride the proxy (the unreachable loopback proxy rejects) rather than pin-connect. The effectiveProxyFor selection test moves SOCKS scheme values to the applying side, and the symlink regression files gain the one-line rationale for their Windows skip that review asked for. --- src/lib/proxy-env.ts | 3 ++ tests/clients/cline-writer.test.ts | 3 ++ tests/clients/integrations-writer.test.ts | 4 +++ tests/providers/provider-outbound.test.ts | 38 +++++++++++++++++++++-- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index 9469ccde982..bf2e70b9175 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -127,6 +127,9 @@ export function effectiveProxyFor( if (socksProxy) return socksProxy; const schemeValue = env[key]?.trim() || env[key.toLowerCase()]?.trim(); if (schemeValue) { + // A SOCKS URL in a scheme-matched variable is a usable proxy: admission + // binds it explicitly and the transport follows, so it applies here too. + if (isSocks5ProxyUrl(schemeValue)) return schemeValue; // A present but unusable scheme-matched variable fails closed: it is not a // proxy Bun fetch can use, and it must not fall through to ALL_PROXY either. // If Bun would have used ALL_PROXY here, keeping the DNS-pinned transport is diff --git a/tests/clients/cline-writer.test.ts b/tests/clients/cline-writer.test.ts index d9275ed9b96..ff06f29dbc8 100644 --- a/tests/clients/cline-writer.test.ts +++ b/tests/clients/cline-writer.test.ts @@ -140,6 +140,9 @@ describe("Cline journaled pair", () => { expect(readFileSync(catalog, "utf8")).toBe(originalCatalog); }); + // Skipped on Windows with the other symlink regressions: link creation needs + // a privilege the hosted runners do not grant, and the boundary under test is + // the platform-independent lstat-plus-rename path in the shared writer. test.skipIf(process.platform === "win32")("a member exchanged for a symlink at the write boundary cannot redirect replacement", () => { seed(); const victim = join(root, "victim.json"); diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index 53d78f9415c..57311a676db 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -159,6 +159,10 @@ function reverseJsonObjectKeys(value: unknown): unknown { } describe("apply", () => { + // The symlink regressions skip Windows: creating one there needs a privilege + // the hosted runners do not grant, and the boundary under test — lstat + // classification and rename-replacement of the named entry — is the shared + // code path every platform takes. test.skipIf(process.platform === "win32")("refuses an omo catalog symlink without changing its target", () => { const configPath = installOmo(); const victim = join(dirname(home), "victim.json"); diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index a56ca4aa105..e5614f455a4 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -382,6 +382,35 @@ describe("provider outbound GET transport", () => { } }); + test("a SOCKS scheme-matched variable admits and binds the proxy instead of pin-connecting to fake-IP", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = "socks5://127.0.0.1:9"; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = directDependencies(new Response(null, { status: 204 })); + const resolveOptions: { allowMihomoIpv6FakeIp?: boolean }[] = []; + dependencies.resolveAddresses = mock(async (_url: string, options?: { allowMihomoIpv6FakeIp?: boolean }) => { + resolveOptions.push({ allowMihomoIpv6FakeIp: options?.allowMihomoIpv6FakeIp }); + return { + hostname: "provider.example", + addresses: [{ address: "fdfe:dcba:9876::1", family: 6 }], + privateNetwork: false, + }; + }) as ProviderOutboundDependencies["resolveAddresses"]; + + // Admission and transport must name the same proxy: the request rides the + // SOCKS binding the admission assumed, so the unreachable proxy rejects + // here. Pin-connecting to the fake-IP instead would be the inconsistency. + await expect(providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + {}, + dependencies, + )).rejects.toThrow(); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: true }]); + expect(captured.address).toBeUndefined(); + }); + test("built-in ollama admits loopback discovery without an explicit allowPrivateNetwork flag (#758)", async () => { for (const key of proxyKeys) delete process.env[key]; const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); @@ -737,12 +766,15 @@ describe("effectiveProxyFor picks the variable Bun fetch actually honours", () = expect(effectiveProxyFor(http, { all_proxy: "http://p:13" })).toBe("http://p:13"); expect(effectiveProxyFor(http, { ALL_PROXY: "ftp://p:8" })).toBeNull(); expect(effectiveProxyFor(http, { ALL_PROXY: "http://" })).toBeNull(); - // A malformed or non-http(s) scheme-matched variable is not a proxy Bun fetch - // can use either: it must not count as "the proxy that applies". + // A SOCKS URL in a scheme-matched variable is a usable proxy: admission + // binds it explicitly and the transport follows, so it counts as applying. + expect(effectiveProxyFor(https, { HTTPS_PROXY: "socks5://p:9" })).toBe("socks5://p:9"); + expect(effectiveProxyFor(http, { HTTP_PROXY: "socks5h://p:14" })).toBe("socks5h://p:14"); + // A malformed or non-proxy-scheme scheme-matched variable is not a proxy + // Bun fetch can use either: it must not count as "the proxy that applies". expect(effectiveProxyFor(http, { HTTP_PROXY: "http://" })).toBeNull(); expect(effectiveProxyFor(http, { HTTP_PROXY: "not a url" })).toBeNull(); expect(effectiveProxyFor(https, { HTTPS_PROXY: "http://" })).toBeNull(); - expect(effectiveProxyFor(https, { HTTPS_PROXY: "socks5://p:9" })).toBeNull(); expect(effectiveProxyFor(https, { HTTPS_PROXY: " " })).toBeNull(); // A present-but-unusable scheme-matched variable fails closed rather than // falling through to ALL_PROXY: no usable proxy is guaranteed either way, From 6f6acf65e4d9edd90c45909d53ad2ecc84b38e58 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:06:22 +0900 Subject: [PATCH 36/87] fix(gui): report the measured cache hit rate and make the usage models table readable (#5333) * gui(i18n): add the cache-coverage strings the hit-rate cell needs The Usage models table is about to explain a hit rate's coverage instead of withholding the rate, which needs one string for a partially observed row and one for a row that reported no cache detail at all. Every catalog carries both, with the {measured} and {total} placeholders spelled identically, so the locale parity gate stays green. * fix(gui): report the cache hit rate the summary already measured On the dashboard, gpt-5.6-sol showed 2.8B cache hits and a hit rate of an em dash. gpt-6-astra, k3[1m], gemini-3.8-flash, gpt-5.6-luna and grok-4.6 read the same way. The summary was not the problem: calculateCacheHitRate averages cache reads over cacheObservedInputTokens, the input tokens whose cache detail was actually reported, and returns null only when nothing was observed. A provider that reports reads and never reports writes is observed and has a rate. The table then required that denominator to cover the row's entire input before it would show the number. One request in the row with no cache detail - a locally answered turn, an unreported usage record, a row from an older proxy - puts the denominator under inputTokens and blanks the column, which for a busy model is every row. Render whatever the summary supplied, because the summary already refused to supply a number it could not justify, and turn the coverage into a note on the cell: a partially observed row names its measured and total input tokens, a row where nothing reported cache detail says so, and that last row is now the only one that shows an em dash. The note is both a title and an sr-only span, since a td is not focusable and a title never reaches a keyboard or a touch screen. While the table was open, lead it with what a reader compares models on - model, provider, share, tokens, API list-price - and follow with the per-request detail, instead of burying share and price behind five cache columns. * fix(gui): scroll the usage models table sideways and pin its identity columns .tbl is width: 100%, so the models table divided the shell between twelve columns until eight-digit token totals folded onto a second line. Give the table the width its content asks for and let the shell scroll instead; .tbl-wrap was already overflow-x: auto, so nothing else had to move. Model and provider are sticky at fixed widths, so a row stays identifiable while its numbers scroll past. Both offsets are one var(--space-3) step negative - the same trick the sticky header plays with top - so a stuck cell repaints the scrollport padding it slides over, and a value too long for its column keeps its full text in the cell's title. Under 720px the pinning stands down, because two pinned columns there cost more reading room than scrolling the table does. Every selector is doubled as .tbl.usage-models-tbl. This file is @imported from the top of styles.css, so the whole of styles.css cascades after it: a single class ties .tbl { width: 100% } on specificity and loses on source order, which reads as applied and does nothing. The rules already in this file buy the same margin with a .usw-section prefix. The excluded-request caption is a block now, so an amount and its "(56 requests excluded)" are two lines rather than one folded phrase. Its leading space stays in the markup: a block box drops leading white space when it lays out, so the cell reads the same and anything reading it as one string sees exactly what it saw before. The stylesheet rules are bound by a source-oracle case in usage-layout, the doubled selector included, so the single-class version that looks correct and does nothing fails a test. * docs(devlog): record the R6 usage models table lane What the hit-rate gate actually was, why the summary needed no change, the column order, the scroll and pinning, the cascade trap behind the doubled selector, and the screenshot gate this lane cannot satisfy because builds are not permitted in it. * fix(gui): keep the pinned usage columns opaque while a row is hovered --hover is a 3% overlay rather than a colour, so assigning it as the whole background of a pinned cell left that cell almost entirely transparent and the scrolled columns read straight through the model and provider names. Paint the overlay as a layer over the surface instead of in place of it. Found by screenshotting the table scrolled sideways with the pointer over a row. Neither the source-oracle CSS assertions nor the happy-dom cases can see it, which is the argument for the screenshots now in the devlog unit. * docs(devlog): add rendered evidence for the usage models table Three captures of the real component against a fixture report: the new column order with the two-line price caption and a hit rate where an em dash used to be, the table scrolled sideways with model and provider pinned, and the layout below the 720px breakpoint where the pinning stands down. --- .../060_r6_usage_models_table.md | 95 ++++++++++++++ .../r6-usage-table/r6-01-column-order.png | Bin 0 -> 94143 bytes .../r6-usage-table/r6-02-pinned-scroll.png | Bin 0 -> 63741 bytes .../r6-usage-table/r6-03-narrow-fallback.png | Bin 0 -> 61169 bytes gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Usage.tsx | 99 ++++++++++----- gui/src/styles-usage-workspace.css | 116 ++++++++++++++++++ gui/tests/usage-custom-range.test.tsx | 38 ++++-- gui/tests/usage-layout.test.ts | 24 ++++ 18 files changed, 353 insertions(+), 39 deletions(-) create mode 100644 devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md create mode 100644 devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png create mode 100644 devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png create mode 100644 devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png diff --git a/devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md b/devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md new file mode 100644 index 00000000000..e3bd25cd139 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md @@ -0,0 +1,95 @@ +# R6 — the Usage models table + +Status: OPEN until the pull request lands on `dev`. + +Four defects a user hit on the dashboard Usage tab, all in the models table. Three are layout; the +first is a reading the table gets wrong. + +## The hit rate was withheld from every provider that reports partial cache detail + +On the reporter's dashboard `gpt-5.6-sol` shows 2.8B cache hits and a hit rate of `—`. +`gpt-6-astra`, `k3[1m]`, `gemini-3.8-flash`, `gpt-5.6-luna` and `grok-4.6` show the same thing. The +reporter read it as the zero in the cache-writes column suppressing the rate. + +It is not the writes column. The summary is right and the dashboard was throwing its answer away. + +`calculateCacheHitRate` in `src/usage/summary.ts` averages cache reads over +`cacheObservedInputTokens` — the input tokens whose cache detail was actually reported — and +returns `null` when nothing was observed. That denominator is the #4546 contract recorded in +`structure/gui-and-management-api.md`: a synthesized zero and an unreported detail must not be +averaged as cache misses, or a pool that discarded every warm prefix reports a plausible hit rate. +A provider that reports reads and never reports writes is observed, and it has a rate. + +The dashboard then required that denominator to cover the row's **entire** input before it would +show the number: + +```tsx +model.cacheObservedInputTokens >= model.inputTokens ? model.cacheHitRate : null +``` + +One request in the row with no cache detail — a locally answered turn, an unreported usage record, +a row written by an older proxy — puts the denominator below `inputTokens` and blanks the column. +For a busy model that is every row, which is why six models with billions of measured hits all read +`—`. The gate arrived with the cache columns in #5268 and was never the server's rule. + +The fix drops the gate. The cell renders whatever the summary supplied, because the summary already +refused to supply a number it could not justify, and the coverage becomes a tooltip instead of a +reason to hide the value: `usage.cacheHitRate.partial` names the measured and total input tokens on +a partially observed row, `usage.cacheHitRate.unmeasured` explains the em dash on a row where +nothing reported cache detail. That row — no basis at all — is now the only one that shows `—`. + +The coverage sentence is carried twice: a `title` for a pointer, and an `sr-only` span so it is not +mouse-only. A `td` is not focusable and a `title` never reaches a keyboard or a touch screen, and a +cell whose whole point is to explain a number should not explain it to one input device. + +No server change. The denominator, the provenance split and the `null` are all correct as they +stand, and the structure doc that owns the contract stays accurate. + +## Column order + +`Model, Provider, Share, Tokens, API list-price`, then the per-request detail: +`Requests, Measured, Input tokens, Output tokens, Cache hits, Cache writes, Hit rate`. Identity +first, then the three figures a reader compares models on, then the evidence behind them. The +previous order buried share and price behind five cache columns. + +## Sideways scroll and pinned identity columns + +`.tbl` is `width: 100%`, so twelve columns divided the shell between them until eight-digit token +totals folded onto a second line. The models table is now `width: max-content; min-width: 100%` and +the shell scrolls sideways — `.tbl-wrap` was already `overflow-x: auto`, so nothing else had to +move. Model and provider are `position: sticky` at fixed widths so a row stays identifiable while +its numbers scroll; both offsets are one `var(--space-3)` step negative, the same trick the sticky +header plays with `top`, so a stuck cell repaints the scrollport padding it slides over. Under +720px the pinning stands down, because at that width two pinned columns cost more reading room than +scrolling the whole table does. + +Every selector is doubled as `.tbl.usage-models-tbl`. This file is `@import`ed from the top of +`styles.css`, so the whole of `styles.css` cascades after it, and a single class ties +`.tbl { width: 100% }` on specificity and loses on source order — the sizing contract reads as +applied and does nothing. The rules that already lived in this file buy the same margin with a +`.usw-section` prefix. The source-oracle case asserts the doubled form, because the single-class +version is the failure that looks correct. + +## The exclusion caption + +`(56 requests excluded)` shared a line with the amount and folded mid-phrase. It is a block now, so +the amount is the first line and the caption is the second. + +## Verification + +GUI change, so the screenshot gate applies and this lane cannot satisfy it: builds are not +permitted here, so no dashboard was rendered to photograph. The evidence offered instead is the +column order and cell layout written out above, the regression assertions below, and hosted CI. + +- `gui/tests/usage-custom-range.test.tsx` — the partially observed row now asserts `90%` where it + asserted `—`, with both tooltips, and the header sequence asserts the new order. +- `gui/tests/usage-layout.test.ts` — new source-oracle case binding the scroll, the pinned columns + and the block caption, so removing the stylesheet rules fails rather than degrading silently. +- Adversarial static review by a second agent, since nothing here may be executed: it reproduced + the cascade defect above independently and hand-evaluated the rendered cell arrays for all three + fixture rows against the new JSX. +- NOT RUN: `bun run test`, `bun test` on any single file, `bun run typecheck`, `bun run lint:gui`, + `bun run build:gui`, `bun install`, and any `ocx` execution. Hosted CI at the exact head is the + only execution evidence for this lane; GUI lint, typecheck and `gui` tests all run in the + `gates` job of `Cross-platform CI`, which a branch push does not trigger and the pull request + does. diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png new file mode 100644 index 0000000000000000000000000000000000000000..eae536967f70d1327b7e527c192fd3a5681ccf5f GIT binary patch literal 94143 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 z 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®U`2Vq^Bw+fmOt1Z63!X+XLWvg)``g- zA_4&Ku5dYC>H3P|fbRxnkKxT@O=N7(H;adNfY9g;^?MNLms^({Wn44A@|K$c4^`I1 z+)JMnm&FN35kGrJ_5)K{F8xJe#$`>C;TvS>XZ0lM(z45y4;sX$-X3?V0Kc(a%)O>kk!<8>l9umP0riMaN z42JHr>)(i#i+ef%ybWZjA5o-ihWl{jpwkYAxB@aDv^8kOS(+(B%K(UYk(6yC!nTGJ z8pg-`(x`|%v|b2b?d_y4*b=;a8CN;TRqBji@B^#oMDfoN++-w2BIAog+uyw+8R8SX zPV%o%V)BPc~=(l7w6l30QOrKz+oU7OWE)@#EnROgTsJS6(@JFx32NR zjlbQTb{r&ZQ!#i}L+$f)F+8P9aEcGO@iw)>C`txR%l_Adg8&PhIcw%e0bwnI<`4VXC>;|Z0*5@8$~Jj zAoZ!+>y&RzU3m`%>o3xtQFlDoq{tfEIGk$GFzY3<8cnwxkESII^r^UM)&t$}TtiA3 zj`(eZbw{=i*VT_L`@e#TH2BDQ&lNNJ+~$}rP3QVK4ZpBK$SYYXlO>Cw+RaBr?WZrLh`&Jh-5(|3;7q(x=oq#ds1BUu2CPX+;XZ*M2u80GaN$&0g6<1#BX(`n5kw*#QERy;6Tb(z_4t2=BoE$@igv{{hyuZAw zsj?ncw1Xq#pw?kE!NfRk=9`OufJ5f2+Y>roC~Q3=ph$(zmHy zvr4{1gs47|{v)5;TE7OBwk)kEU@V~|7BRZ~nf+*VB=*pG<+$D4xN-^^mGyvCAL|$q zYY-@indnM)?kA`u!F!m$A=Px0-Soii_n?!+Gu4wd3H4axEZs!R_1@EE^; zLsh}v>&<$#UcbEh?s(mdBjAiXeSE<>DedfsU(;^|ZTjJCO^x;LZT7bncaQS>8=?(z zRs^T|6H#vjma2xU*@)P>i<;XG2PAwt^wzs!*7+l3LE$zFH5Mva0j}BwsW0oP!r~RJ zorAee9h0Wnu!ly^Uk%hxqDZ%FJ;SX@m03zli0ol|p=N-TRZwt!0IEOp2leSIJb{|$ zR2{E|Xgn=O<+z?#MotaiDH>9_Zb2p__JU?w)cwMVKpSr>Cj4U}O;P#PSpFbi@E|c$ zGYO?f;d^PRW;p3*DNSysJ9mqSn6}{BM{_mJFahQ<2Hb0bjqd>g-v68t@)F6MDSCG_ z(bp}8(dq-g+ct5R&*XU{o>NqH+jH z6UwiGPyuTV4BF2szuFwb@RFHF%}@lM-ND176yDBo9%_rVtX+ZbO9B*OtIS&+FLkO}ToiKnTxRp14MLE-{(v<#>i%E!zzxTAyk~3NLir zD+9jLDa)@(7@?1ChW;o0Xhsh~Yk`WSM{Q7i-d16DX?W#ZI){n5+(^yWWkB2RH2Gu6 zeF(X04M?X0mMcsOgyDGNXCwT4qXYVw{5H%-NN*}8($k-EnLmG5C~@C8hg9NQqIM|# z$5V+Y72e!u21=W0*O<6Zdzgi3?|MJ9dtGS2$X*cV_IA0~YIVzcpZ$x==I6?RB*%8~ zXa)MdPLGPZGRArG?!Wvn)e)ZM6sfPE$@8zlIm??*vJH%1r0^#h>cTbfxa-RTbKf|?dSd%77fzT82g<+$r zc7%@d)AzQP=Lsqoa8;8R`Dzb_MTL>jBLxK}QcRV?=GM$!$pSKo$dZtf$!vtUe01Yt z*@GlQ)!@f4%Bvm=gB7faowb45^iSuV&TG8W+0JvY@o@Y_bJQjKPfhsDX6bE+evCk>5q(^purY|R_W_=PiR@+aL=IOFNyi>D zMvGj|M1dV9cVb`TC{oXfYSuls1}afthJu1_h7BZ)JSgqEgPw;ETju(arj0R8OLM1* zvUmJ8PXp5g{}1vT6!cT2`t^%}*i>`o&@upSkN5KlRPu-r#Wo`MCH+A?&ooo~!`n1? zB8&bYvS@F)05>rcvSvl5bijMabMm0zihhEDn70#=9y_l(S(xJyOJ|9boB+9g^!1i) zFSJb=aXnztQx7HO8j*$dgf-o^71jzg{jk?sTj@YXdJ5!sarp=3z<~`Jco#@G^B+ci z9$Euqu(5=1G~{w$s(!sBklPWXm^k5kYF0z@7G7sgtn1qBv zK6@oq`h2Xk9wpYcAfowjWjUO*@KcCf_Gc zF?{_)3edNdj9U;%GB`Hg@6SA;dUdN3`vpXRWJ0Z-vnS8A>Y#>*BI6GP4mE;g?fu2< zkH&JS+k*i>ND0WF;}4i_ujYABlA|N^VgbMguy~*2H|GnC2Ji9&ss3>$GPO6wqbW!1 zb4*bqEI+WZM?6Nj?~_@UtI(Q;czIDR0UoiV$4I3Kt5ECS5OnZ+SeF@B+Ji=J!BqlU zr)e}Ry3Aig+DoK3S8-6d^z}qG@HnbXT)1DHQspx0B_krW4WtZ404GGJbpH-U65T>e zaMv%$@ng2eBZ$;jP2Btf5nwlpvD}0bJhkEXH9;ptIZ{by3u0f;0d-%c;9Y5iH41eO z3)0_UV^N$6?ftZdW@|+071=(FMr%DGkjxafe0<4R8|V3zNJ6z~CXMZ;b$J$vbtoK$ zXxbw1l;f(NiokMRxYiy})!x{+8OX1BY(bGFpm6zTBG#jGwHr*>OxHMe^lp=bIlILT zH~Z+2*E7KI{z{D|uLvTbrzSxmz-0(?lFydkp%WNq{>!m$v=?1I9(cEFQ*z5iC$!16 zNc6W*SN!*wevpU6_41i0oEHU^9?;Xp+uTea<`syQEy@cVb_8oJ(uQz~wcqx{YHmz_ zX19tMjZz)gQWUfqePHDdSC**NgG&6xqQL| z_#*pVVkq@w96nlE)%su4&$+%00Pkn6WX(b3^}L^7`gYfrktBpjx{*JstM7-%iV_nj)mYo zmVbpzoj~J=x{0%j^W1wWxt{g7m7uul~l(*cNN<7kI><)Bp{%GFAyn=)f>*v7@ zMZ{*5NvvVOMhgTiZyr+#TB@zONIqh}&PB~S&iiv(PEx<^b@y&Fv~|4{M%`XFuE)Ng2qc@Kw0m^K0Vp)k1q#S)?q=kQ-R!z#;T z6$X3Ug8|*;b!{OC1BO-45g1k-4XH_j^eV<=qhCL^l$xH7{#q`X^#%CC!2%kEF3B^W z79!g8n@^*Cm)@_m=hYp)+IV%za`%=~FR{fUQ1tnkuVs=#>3rjgK^?y!scMiH z$dVkF0bxB@XjzP0NnnlaAK?^yKt_VDxe8xM0Mkmvb~>T#Fq`0BAGFx1>*d)+N{I7T zKysDV54-#z#D?bs^n~RG6t&J2ji`;bV(eCFJkn&Z1m*6!&=iw$X^BBCRny7=TKK6Cw<>>w{JY$@njP8fBsr4_HJ|?ivXbe7-|5!2!wUYHH@42PG zRO#4&_mW%7)%XFrG5ratMSS4yx*7AXD~#xpRUI(mEfUA^f8ag z_%||bsO!KYY2`c)tRE;T72uMB7e|E+7 zH$n+>;Q#9k8r0`0!4Q^Y%499C4rV$<@MOu)5wt}cb=>RJ28$41)$?U*>57DS)&;7$ zh6rf#Wp4UohTCuF_vhg(iTvcEP4rTvEVVK}30x)lMC3d4vNuGlQ$*^pjo)87i0tN* zjIAch4)>g8KvKm#kF0;)w|g<5uBK477I9DNPPl0unwYQY4xO;*7qsU1O$n3s;Px#1 zGeE*rNGT^M&Go9S21e*hhACDfEri=qZA>fQN z1X=G22l_Kwn;H3DfYsYONkuK~+h6n1NShp(&hQa8L77)_5%MxhD(mT@6qyW=dW*2 zPX%ENyOBMTg(wRnhCS^I1HI-eDwbdff>+;bS>{Fw16xMzNk~h^ZNcq@Y3>_ix@4dx z6a7rm;51pNz;4^iO1>*@=Bh}2AC(Cgxxyka$5t=d67HxuHfQnP&u15~+;~Z}#wp35 zeH|of7DvNCaW~}`7qUXncLA{2!z4Km`gUVLDTf{GNBdK2+#Mt3vR zDFA2oKH@Do1(4M`6W>BDW%$)j%dg!p9DOT#7pbkJ0avd`W^NUSg4mZqdRxFeCfe_| z83%b|t#|Rx_rdH_u{vdWX%dOJ%is7!(YX4CUAGg_Yrevspt=Iyvt2+Muu|G&JBMH~ z2+2W#KQd|q5Kv7`O<3k7dO3t|1DqE%^U6V}hZ%2B5OgC`6W!SK@5@1*W;H1E>H{oS z;W+%w3vl8*T&;ecYw?elhN_<~vgsnZbG@x4>! zkwFauAGufb$#z8YC5q8hD#Gz3FxvPSTwhnQ%+qQBCm(&{(=LfFdUF?wP7#*QfuGlm zWe_&Tq$HUbAtU($A_Q=rAV!KM5jZ&-l0S493l%jmFYEoL}641xLr2}y+jaJ z?6cL&VqHLK1~JLlBNVI3hgs6h%WYQQSSBDB&txs=W$lHvx| z9C_Pi@~?nv_6&k#KJsqh5g$lLn#3;M>!Nx7NLzb3!#&CCDU{Kgak19wXI$I~S#BU^ z(S!0(Fwhw~pG z_)q+2@|Qt4mQ3J*iHOss_^YOEaD-zT`K^Yie$mt+!oK|d`)LCmL)$ZH+cs_2h44D@ z*5*P}XIMvgx4EzEGP58aR<9LdQa~8UHSd|wMer(>S=9wS2ZGnZL#@ve{6-P1f_?8i z81`iCxN^5NKFJ;sOSo~!nKdb&^WoLolF2^8oIn8>abS53WvjsxR5n*dAmNf*z-`eem2Jh7GE4hb+%L*tVJ zCbEZPx|x?7-hkZG8sC}-Sh-4`IIrotr%y-sQh6N8NL=Q9ei|eu0$ei?Qp7uJsjf+I%8N-h0MCZfMgZ$TT|T1uq{govoeq%EpfR6`WodB=Pnfm zU<3EZ^AvcFl3^Iwg2f)zl&xI4MZosau`)zrO%A*^1t+@jJ>p;8jKlu>rT$TzgyNz_ zLkrhV6Z&|R*H|%E=55N&r&(2r%ORwOlTdt0a$ungC(Kq}~_`g3P(jh*_ z+68K&$CGB@M7To5dJaVm5=7DBJR)^=n=!6K8APq6S!FpWU>0as4e14sRw`qL!1S&P z9+Z4Qq7e{Wt;lU{qI3$HgwUi?;N!)pMcgI{K#_ck5fEveMcQv(r$j<#n~yG+U8Mpl za%B>sYuXh;5z~kbr}>H#7-iW3xDaG+&g0Xs%HIQ;j@2}uMUgY!9&dkJ~OcogJ>w;hjUO4R%71#P(8tLKJay14G!zi`RVCLrUGDpSn^J; zG$6jT89b|4>Eqh*d4SRon{%Bs!5*Kt^9e9|v_9sJB3HdwV zZ~ppU!}}utCEUULFHO*YiHH7g{FphW^9LB0)`e}CS3Vmew-!F1X$QQ_cP{v)Yzafz zusLtipCR{H^`8&Fgn4xKzu0C|f$r(=&mjLGxcXn#*8h^sAipsf-|@ecPyfICSg42t zbWus%dgHA8uu>D4?16aZh(8ywB?j;KUW)lpMuU79At)ez{NcEhCGjT1DcsK!1oWbP ze=QVCHq)d=LpC&{zl-2_k&{Qv4wO2+|iKu@QyTs;b%tA|0r(^WhiA zWpO?zg#x5RHHjm`1LiRSe%=Pfs4@hQ0cftif9rn{+R+6I^E&?dh(iB}%?Px=uYFpm zc{~A9o^J!GRiKN3NzFchDMsXh>8}OFlQHPH;qUf4=pubkqI&>!UHKnK{q<08-3F!5 zVp|YcD@(wrerSSoJJ9zzIT;|USm)Icd$S3~&@Z|5PM#$q$VHjk-hAEys0Dg?;C}P! z)ougp3el_y^f(Dp#;yLRhij1M8o_A*hSHHmtTsGp$SK;mdX=0t&~_hK#l# z^lRuTXjzmc_TC>t*n97?p;h|T|Y)FgTyD z)WPCcA=m9m)2{G1LvTq*&uJj z3Ss|!=_g=SBn&X5KUaI*xq>tQFJz-Eq%t5C(p8gr2A{y`RfQo-ZZFwXQ(=QfmyL-s zBao)Q)hRN~4}$ZC*9CKG{HyUN5f_N81Bh@K==zwqGMJt$U+Z-QUGyR_bYQuTBt2RA z>;L{JHy)2ufdUN9hzm%Dj^R5&D}joR!#CE3_;on>KCxBdxem%AYrUt+y9}#(o!$Q#pr*GjToS;`@;C%OL1KEza#gA$$(qQpHBLoeR zs==SwnX2lkG;;hpy$i-6;Op=N_5rAnB7r58i=WT}Bkoy^<7Ly0;1fMi5OUFL-GRG; zO~PTGPl$(EA8tEMx3AaYeY*d%=N7wOtsUIyOk&gCj84_7R2uhnW!=N=rYc8b(DWN8 zX*q`AZCi$k@Po4jv`IE`|0$WKD?0r~Xz~Zc*?i7GJ zcxsNIcS7WAB%JqW%S}+jV6608S=B5;7&x!=n$*HE_0EiH^1x{WT5%|et|sN9#Ui+2 z$tNQcC`6qi^^(URt=QBIQs22MIKF_UZB^qAS|Il}wQ1Yl=XihYfU8a7k5H{VUmZzf z;#3iPMCjC{z8v?DVPR)y2Y34MlqUSV+6k81U;6cG6HL#Bg^1dA8ToUVan^5Y(sn$h z$yJS%PlZy57Oc6zNW?0`@i_@5m?s1gSbDqwGZJpcoa;={La@&g-qX@&=>eBFd)u=O zRjG4y40rvJLSQH>*tQIO2!R@^foc4Qb!NoL;55N`gDMM3C|to&&sB zWv9Ld%CaYL2EpN9O&A)P`$+GTi*_Wd@zMS7b3$@)YaRp zwD=8$6zfJ8RvjFgYhS1i1T-??Uz1PoWQv`fJs!&;PwcC{LIr;N&VFo z={YYT9FX;yf2s(+4R{BVw2_mL6Qwn&Mgd>EoG;)O(Ucu_}N?gI7K+0r4hX7ZT?GRvc* z@>0JE%r1wLH$IiIstK;{+-JpIZSNEW406b1x(RLAWN%>W#LfZwjq01e{N5WrB;$gYy#%i zs;0!I6o>RQvKdt0rZw#mmym^0gC=CkNB2?6!xW3vVo;9ezSnczQ(HtUOJa@2-JHyX zfg83KqFpf_G)j=(6enFm)0-66J%iYBXP@X3mHNV(&MYq>$y|mwKR@Z^Yp!me9eK!P zqBg@AVT9_**ewRReUhYUKONaP28DIq!P(L0fmg7#S`ojFJ`yWMN%O0@G@fbr_LgU~ zKCK~ZUo0suU;hB4H)8d&BKmFtK=c3ur65<}0fyh$LedgvQ1*@QaIpn97m5l=Ro_4* zkKf|=Io)#;-x?zFj!x%Y0Q|U-Jo;x??wZf{kF4bH(Bh*Y2Y&+9?fM5SVmAL4X>={; z!MY231HQ|`H6=Gs4H8 z3mrk4GJJoOWTfaCr7(^Ke`i`StX9t%CQM-#3NMMqmy7BO&FRo>_N)mTALTU!!6GCq<|`f45mBf%dBghrQyW2| zav(*rz8;3jD4M=>a6}Gm>N_YID@?n_%ZzRTvp$Q{N1~*jDV9XNIsPuEOXA-OAMLz>Z@;P)w&i4^EV_z*JBi~9 zXig9NF1;hn;g-0rcBd!&>zk?UNxE;&UEw5BNt`wx6SY<<8$Boh)S zhmF<&64qmya)C>Ux)O$)aSshy85T8(NP3#e&tFa~t+6%lE=-%6)ovI$-YT5SQ6R_r zDT+Bvm&o-n-d4FQXFR>=j{KR&%=71Q+g&(LqrZ+eg0vug5eTM7qGozQm2nczGQpjw zJph}F_ne&uuKkhk$9w%KU@=4JM8X2kd`9ID6fpxaPW+Y}8S?%#eRe4!&6z0<ugl<&m)gT`i#zDlGUrhGxeJqFIW{${TS5`vQ`@6HzSz zOPY8Tdu)D>?B_#QU0biV-S!4^s zu{05)L*ZOT$FlXi>j&(@C-;Z;UlXU3)$bGd`e1(Ahc26OGsM^tfYr1tIyCnjG8J>I zxo*f)5dx-Dxzn@SpI+sVktQ;<V z7J!>8-1t!o=WI@d_Zp#ChdM#-)s5WDAOoChCk5$G7~Zxf(H&$QX>d1T%YVoau;^1! ziVIuUZ1#MX7=d6cn4dhnG8Cg)P%wiqI1K1~11+x2bg;?X6Ox5pZ|;$gFy}Jy=+#CN5fbJkK5`WmlG|#aS)n)8&Is7; zv|7^5U9LxE7fS9;e)99ua+uTYVEpB<`T%nL{t(f2+_VVt{Ft3Dwf1^yC_>L#^7+!K z@xvOn5VSXp+AERh5xKu9_g41wY5OFbK&> zyhc$5Gy|GT#nw0mLi@Q-6uN)shUNCwJMwN*Gt8}&^K;HMEd`jh8e=pN5&6;Y8@s8K zs(oI{L;kh617NZ<>*$v>le?Y7{5@flsTL!W>TvWJVx&6T4{Zg5NN!!V(^z>v93E-$ zqLlyCp>{6Jo9VlfM9iA|s4w>}+P3bUv|CF@(_}l_!(gcMdZX}^Y;7nuHd6a?7@Kw+ zCI?dZs!i5AGD_8-av1BmX?as18Q2*ABu72h<3qb8%(uLhOu~sZ6H$UHyB#}sN{KnI zyBX}E(55wIZAdi9pxX@bvQWszh)HbwCYR%)*n{RBFojzq1W`_zdj;DB>wPDFOomoF zCT4PldTJ9W2h==4QXH4P;k%zdq6%It(A(W+w9W)^n3Pp3zcA^tzZqY&eBi&$wunklDV;|;e-8%RlP^Jb=fvkU0kmc7FV4q`(2;n z_uI$>;%=|`#<@ddZZ+l1wm(w&jlNM0jd6v>S$M57A$?Z;u`WOB?fg!sr>}9N@<`|h z`kNp8c1Y;MUZL4c z75Xz+6Hsm<=akgqPpL!|Iyh58aNU9p4KgK;pP#KkjqR5f*x4?RmC-TmT=rE=ES|Nr zjInRnK6cJBG0GywVla;Js#f-gk`Pk$S^rGTpcG<3({?)|KS7>4i)+ETUU>9f5vhh{*0G3JqLCO<7~$Z>(<(D{JS!t~mw#OX;`_0>;99PYjrDv*d( zCG#8SK3=Br71=N%8GwSbr&=#LeehAgvv`!NQPxT>@`($x{6wGD8Hr29yCB- ztXgHuU1&?+e^(V_e0%To`?n$%`Etx)hAGw)*+rDSXdWDe=dVc9?AANLMw^Diot~1( z>|!*D=s^eCJLs6cb^#z7O(WT7kRCEBsa`HDh#q5TZ;8Qi@9hzk4a?usq>cIbeU6$ar8Lr|pB)!--&I30!PLO0u5x@cXmYS>tVLy7+* zHP_*Eu>_~4X49XGDlYhI>I^?Q70%h7?39~yc>4}nT^2<3#LW*EdrE`8=167pNK(Rl zIb-ZN73E4B5BhvXTZK~E#&pdV^hgce-D2eRM?+3o3@>%c3^OhJmG>|l*j`Dr3=86i zT$3_^LOCPGPFz(VdSvxa@=P3XE!IVbvZzAdt_vjw|C)8;ny_3=$)idmb}e|wFSmzN z&1D@C>O-Z>ERgVmaahD+z~gqr!i|JN%Dc`Y!=b8$;g}X4J^nt2Edvu30VI0SrE}&M zOlqAh4BJ(X8p==OCB0m;eb|n-7Y8<#5;P`mGYwijLfZ`W;H60CHKvPZp}6@;vg9n) zDyt$`nQWka9?_u-UXfdO@#E{>Z-t;>HPuW~gRXLUYwRJ$?1_d;9BU>ouO-@h@lAGN zvXL4X2>O|ai39ao4Js62r`reFSk zE&0q9&MCOjM5*L3RN1ef>Wh|mYS-YB=b??iy%+J;zP%zvdi}2X<=1NEwL_J^F#^X5 zKLKLkmd&C2bE&I+74?+QtQ$vrpG_9_I*$g|qLU@BYKej4W9jHils z;{^cG+}uVSvrJzp=7sKO2tWyPF=dq<_1Tog{NSO38@LsFe*+bOjNM^!rXv?NF`MzI zBXLnRB4#scm{`fhgz}!iBJg5V@(22@X$VnPp{j6NGj9<5&RP8G{oVQ88hV0aouaQ# z$WKLm)1*0f0?EG0tvmYi-3J!^&lBc_NO17a4GB+}X<6Idna@2kK ztgM#b%i#m&D~zMJmm`%N0jo{__6&b`&|0AH5H{qgUzJfZlOmLQeFlup5SQPkHnW<6Pp` z6SdniR4Xwv);$@enz`u0{-^ZB-SWDuJl(OFD>OATm0hHE?uTct<+XSab!9Z>9XfD# z578bQC!k_=E07w<-&StRRp~t74;q45xqfsf69D$WB-ge8|Cu5CtM%_Bjlz`^DsSRL zF$iebgL3<|{b0hc-jjmyOjKzk7;+@w)jj}*V330=N$%GXllPz>pWO%3;mokzD9;x> zA?ka+3bC2&LeS{R{z0@Nsl&tw)Jm^G-5tc-qj`czw~e3rOda>(enX(rK_%k5z_Aa4E!?RBonEWxb&YN5>x!qbr}1K0LBwxVHUq$~vh28(bb( zm^7T2t6GoU4KteR#O)7%f6j)!gDeSct5Zn>591xpF>VeHF_9V(O@-hEc{93OoYDuk zJ;SV)Uu;4$s=Pal_Gpf8!2tCv!xcL4>e}k8V096}FP#rJzclm$P|x6{ZJHVPdh%Yi zOvX!JYT7Sq25Jb&pt2(IS;k6-^p=d32f!O+yLZE8ze%Ku`kHQKd^)khjFA?+EvJg# zQTfF|`yswG7)i>HsS4ia{0+*URMD(;I*u8-w)76za=2vJ+WrCol@Ft{B960d34hX2 zQnT4|amVE1l2_e05@#pJ)meolvZD?mf{I@%doU6??)7o$5K!o2=)3fhM0vn;Q@J&f z)j_8*w%66$J!*c(Q&#E>b5}!Vn{@(jg*D*2w>IVK2|2Hc8?YK?KlJ+v);ugjHiQjZ z$P^-14mJLHEJ(4B_7<{zV@0SOKd=cJ-KSfUUa_5u%B^^I2o(P=FKC}(fkn(e9x%KPu-6mBf+Ztn5 zF4sM+>|D`Jf+a#Z~1R5V9Lr@p#Qp5_;v1ZN-7PzaeewZ zzN~VUB5<#UHgN_JDA^FK5wVl=C#?}nl_M68o^sa`9DU~)}b(ed8#0iWC)r=$n**~R+1 zPYhr2OvTX>raFY%ILZu_vVpvG9V;;rozHv= zbfL_JWYw}oX*xW=6FDD5m!8AK0>9LfN*klB2|r_kToC(nxyOn(gddMbqEitp^eHn< z%P#W!(-6_dSTcQ7{CHZIn)A^=#!x=S+O0%)<6QovOZ*`{k%MxAoO(22y{Inx#VFdt z7Zmtz9eZWTD(~2&{ai;s22%x|rnZd&bab6B^Mdi*T_cneN4Emi5NG%C$y4gXh$XIc zZbDodeOvVeV}+aYp}$h1k_yJ2p9n9C+Ek*pxYM?6+zJk*xgc_QZK-H}Idn((zNvJ^ zBC`Eeim;Xc;5iJ=7lI_E%IZ@sAx@xOg;Dt`AWeDw!cVU%#Q=u|dJ+2EFag%gB$m@x zJuWXltFvf{+c=_oA2E2!H<@**CNr2#o!W)PQq>;ZxdXzSaJFTIVbaW>Z-PVVQoe`b zyOYYghXCb@KkC@yoI&u4=_Q*cLuUm*uuR5xOZ7!fFJP`dOWf@VD04ssIfMzs%^w)p zOwqmpY!s|M1XP}RNahRy*{iMF7C?R?`k+*wTHMte$Oo=pJA8l4fFz2CM$YPE{5PaD zIJa}z7!7%BxeNr&TCk$vTLigIyCHgAa2;p}JlD31Tbc!Go4Uk!(r4uNV zs)o6k;bL-tcaiak-3updRzcPN!SQ4ko!70_phxbMH3 z;wHPe6(FXauS)Wn87&P6ywr*J*w?vqi|@Y!wK%Rqw^gm?O=Z?0oDkUN^fpx~L7Twk zVTI|H4;C$bag04zNcH{qgH7jQ4BQC^r5IpE{T&!|FTPjo3P5+mo!d-M-OFMMG=(Gl zcRbEuA`kFrO!z2k71JPd1D$Zt<*!^f$RYvw|JZvql%khYnGBKXLSBy=4Vd*`Z6_C* z0Yel55m^jel$dX9oykDM0)ffXN!AL*SS}~@WSa=$e_(6gnoai`;zaS?oOVETZvnR; z*miYo>A1m|^F(g|Whod=?l)bYk0q2?=hrKd<^gF~cv_YDW=A0NO+{fueM&zC2E@4x zl>wnb31o!+8mlPN;ZtK>=UUIkfU~#@e{`LsY3?u?ew{s-0POy_5P@l;?dZE-vu0*6 z7%WpqjeB+%KKMZ^8A<`(1clIpluv{j!z@xS6?}H!7&qW4p=u0~?6Y#|z5H4X`(B)j zETw#<&_DZoe{cI8mynV!>&H)ICpu=AQx4w0Z4ZscALf5qdH#pVNCIyVixoazC|gb( zV#gtWxx#r(1MNx`daXr2)(}-AvudI=HHP1_6tG};#kjBf7tVm7$2WppH4UnNo>u@n z4NcFiYtKFfSwKS;aG%DXQX)AF-;3DdeeharMcF+7M~*_Uu)s?Pt}}xrFba=ajBZTG zZqfn7?z6p+-6(E8(J#KweMBLj3TG?zW$>0vSy(TY(O>O|)CvFvSDev;1|VMK(SbJO zY$?IaeX zr-I1WxoiF(y^utJ;s3HlCG`KgXzBk@B4u7d=nqvC+@gpm$eL5CM35Xk;hJ3oor%Yx zC4Aw(Zx_vodC6n#ETCL|}6^66aN`cNGT5@c=*nlL6>+aUJKWyNfDTIx( z$D>){ zfI}cAL7)_p0MSjt#S>8VfRY%w8;@ks)&LsvfClx?$?~<@{v^%`kUPQBSuj9H>}x^i zF$<;k8Zm>2&ug113piLusNqMJW0i*}*K!H|x{;(y4C0@wd^jim= zMCVkrh~o(CNQa*9H&|m-$qNh!oC7g!tS_ijh)nw-j2(wE9W zq_o=s@%%TOqVkb?M>D6$DA1XM$kGjpwEO5m!=J&@kw1#h34j$LP%VQjCB3W^W^$(Q z2wWGufZCNYo4Y2p=nWhLBpMz%g0+viPUvscRX%1=*YqKDfqA~&9Z)-cfTvkU82>*7 zXMA7#|IwQ<&q|NNN(XOfy!K}5_5j6)q0uGKa!hRed4*t^H=T zsWMmKizA0>7La&j&!l{o(EQFI|Kf#=G$tKy-%IYToc+XoeDG%h#lED49kfv1M-MPj zW!IKYyktYkf>dsTpbTV5M65*3G;gRiNj@BaH4MCo0Z5iWS@Xewlj|=K*mBPt6v*W} zg9R7e1&6RvXu=rza=}->ugjtp;$n0-Mx*?DAd&mDXWOY>U8Gy_6{)%spC?2*_Yy5Y zc?MXV6_;H<-r*vTFZB1UlJ~&;>2ma^A9s|>-q0;g8b4d4F@CLjO%lUOy-} z5Ctv!gYI}0H3T*t;O0rPUs2xXxv2Dk{ccMbIsJFDFmDh4BtXTE%b#!D0n5F?;~24LAfOg(JZ-^0dNK{XgsX}8hiQ9D((TI8wQFDSwxhh9(&vHNOJTee8yfj zwM-ZVU4dlGa#0P90SKLmJg`X?0Y+fHuUbHFHiB0?sD?`X7SOn1JI1W%^PZ>s{6NJRHc#bM!J!f zmRNL$NEkGTfRr>!35x~^=}twYBm|{PI#fWAR6r3y5DdrO7AE)Fm4BFD& zatGPMwqESyA9%k=6;yk=_D!-fB)Fe7sW0utc6iX7q>&%mtOGGwN` zw>PnOT7ntY(t8pOAise*bA{4cwD_%sSfUgf3%16x^*ixWhj%h`QR2Zpu@qnTj&NV)$oPC2U;dUU zWG_@6dFrF<4Owy#61Y#A00%$pooa+!CUd3Yh`DCZ$q=hoq$HkX8@oe)ssdxa9>7^X zzDCMkY4V&d;t!Hs|7?|^YTRN#u_o=8-uFPOVhY;+>?;oYv!6X042t%sjme)@?CYP?&6s%GR z-e`iw2!5NMi!V1DrM6X)c7Z=>zqWMKa@q`!U`EAu+PTPNtRmxJt(omc6ByBcLN$q#`@XSop?=M-xed9jbwEfl4 z3x)G-e)?D#nV+(1-_`X*pe)S=`HlL6$;VKFH9;~xzJ{D5S?o9snFur$jlG+FIX zr_P^TxY|JNuH}CKxm>KnHAbs?Uj7&8y5-gthxwl?g%iINh8dqFf&(PR1S)xwvhyr< zluv>WSvX9{6F6v_v))%1Dx_)ReQ)SV2Qb=zqhL zMCMZGTvne>DCY_>0YdYCHL-Y=(%p=7gxcgF@wgU41qQ1uauSqLG&6} z;f8lRi@n*gaRPf_lFXSJ;UtQ{k^p+vZDKgK6YATur=7xGbnqXuMmZlq^&O~%Z@6r( zG3ip`a@*2X(KV!H*o1P2+O;Vvl&R|HbORgmY3S*f#bme;?pphbVL>n$ArX_nxsv|5 zTFPQGH%Fl4Ubm80tuZyFEN+Li+GvQQLYJMiVliIXXJ*JETf%>ZQE#v0 zv#G$EBpZ6AUyOzqo*R_kI&|^U`p~+tUVb~~W+Mi|^6|BS*RVJtiQ(^lg34VQ{`6c- z5y#n{PW0n=^`VMK9gKqYpCH^p(sVDOKWnC)#NAj$Gv|{gCF>T6i*gc6!&PXFO%6Rn z@}^t+z{wL=Z2_W%VN@<^w7d>edOf!0H*8un z`3}$C!5ljqL&2uS3xQ-esI#e@5;L@=_cVax3xBJUm=Mf-lkBS}C{QOD9+Z`?m?G=Ve@(tVz+dA6C8E1$@e^GjKOY6aS9JOq;5C1V->4b- z(4+&=Czo%>SMRNZ(du}+Yl@FKBl+VWc&3Evj&ZW^NHNe=Mkg?`t7Usbl@&|xLiDQr zLhI$-`_7gsnMHT6m@P907EW$p3VZ=q@>Rf0a7CpZO0Ou&0ts_Zz@p*|q?hR96<>&q zPo+@14)a_7uD7%m#$?a({cNu)T8z#R4%Wz`3J~66B_-b7E+IIEz|o>hCEK$3n1b|A-`z;2&!%=cszM^qM9gK zC8@98e0f{@O0-?)379F4;eFz)wkoBH<4t1bv5B=T_QKQ>Z6WiCkG?ZA9tC^awW=Gx zAB{My zJd4hTdZA@DdgEre>fk6j08uIF@+E8K4+_h$TfXgu^WS;Ko;E~W^a|$dA%KCj8O#3U zixz5mQ$6W8e%;gSFpfVGcGZ678CR{#1s>Mf%3H5r!4?2#Yt81tJnq{Js|MK^xRqb6 zQ6Pt+Ny<(=<8dBvfHE)6aOyfQu&|~)vD`>I5j^)s4%_iRq@c-h4~Nh_ZJPnu!=8gV z15aK~@fQ6c)4PWNuw`-!`?yfGm$K?V1xv=}EzNx71zk%GRjsnAu#y7 z&shGT(8|a&HoYDg8|hf&Wxu~qjtk-T!O0xKMU9hVE>?YSEDJ|ksO~U(b=l($L zWkKK4i!^7noTFd&GWa%)sx1{Oe*>x^^&{OB(E zLbw~xO*bDIZdO_CIXPAhGPCII+ zEPQk*kbVl9;kkLJs&EL+V9CT{xXK;@r`p>fGTGjL=)yBFb@Zxo#nXj7k|2wNJ-?-v za5hb&BLX}hAI6*UsRgxbSRxO;e^jFt4Wpj2FHHkD;Wi}yql?jH;2%dq2vzo503+Q9;c$5?B;(sP|PxMMj?0@5$haFA9L&j#nDYlxFQTrjssG^fVL+P7` z{T)grTW!Edm+n`A3=tu~uYCM#YEyS2u_Lptq7n@r*2MYQ0T3N}wVj{oFNm5%I#zt+ zFGl?LKmP}q4FACp|8Jd;m$cIVd>%;dgWv9Q{=dFTQhKmd+${-nQ_1i;1*2^@HU;g= zf10h8aP{&(Joyha3BTw68$Qtghmh+3{-3dvfDC3ZnJYoh8qQ=SWSwu`b};Qb4E?|K z)~NNQK2x0Sfzr%=BzxxX+l?kN<415N&pH_ZeJ z6_Q(&8R8=*z-#}k>eW-2$dM8fu%zigKGF=#h!z0MLlXnI#r(hw4)_V<2H;`-5cUf* ze_7&{?h4ouq*4K)C*!`{4d6E1{;&ywQxZWl!716FD-XlKgCn?`{ejVLfNg$MEjgY< z6d=Zt+sz(!@`at-jqr*B8eG28a=402ECVJkKO^xX}`@b@!;%lZ$^Qd0!4FQc@VLZBbOuE>OM z`1JaVPk@TSncBKHp#{6CE3kH;qI-Px;e$L3QZ>Np&&MnA)fkS#v1s6#32y?tE?|Ir ze!J~ctqSdFVcR7#mPzOT?t)aPdjD|C>hE{oWBd2J!_EZmn}w=L{mqs94Zsxh$ zQm2U%w{f}t)BVe4wX2YhptwF7C<`41EwgS$Cgwbb#p+<26G)N=ie1AwZOBXST*d=8 z8p%42a5Np_br#)ymZdI`#&6vP&f*6rzphKz52f$G90#PdAJPg97^K}B0O;2d4c~;; zW$>I;!0X2;41}B`HyB}5`rPw);i3946F&LpLxqt82)AKlvJ3#VAw%fiF3<;sgEt_4 z9&F6~8!#H5es5fuo(9G;o98^YITQckB3JN!h2FhSdhzw{LCPegKSe2x?mh zvo7X(P43`O8ba#fx4ps_W42xcwPE|m}1!; zg@20Vw1f{S5(WXEkXuj;3Uiey7slOtpfLcnVHv>guIx2pDePU+yQ6-`rXld)KoHCM z_hqRjO)|>?Y3h9SKLOotft3rU_&>qG);0b$BQp%7P>8W zdbkoOb~{QP$c(R0Vlk2-+E?pR3J-E-WB6DT96nAdnc#>e1X~j@8bvE3cFE#*O*|!g z7OCi&iODZclp_Rz1O3(P@5ncYqiOk=;r@)>6GmBIl3$UawxTZ=^?eGK@Xu^f5LPk? z*5dZtuYSM>n3UBYvge;;M#uz#W=_Ah7rrY{FJ<}Im6B<%7Z2ck6LxNT0U!R~*#a*q zxo%xbGms$p)kN)7&La!cm69C{|633w6!qTz%YARCv$^>64D!A0IIt>QM3t$dEX#*u zCAJt!ogN-8MarTY>TCx>t6bsVd5wOBi(m{W(m_Z^+3m{i=sC$M9;}!`pE3#>r(NBu;3&xUu8Ny<>m*- z8SnKdTp3`pC*l`v7*|8bTCIUqi*x!^j6^ma9HHPfBYm_E@HrT;$4^iGD4*dEcfegT zhdsxeg{ygvmGA~+sE5bGqgxAlJ7@n`h|-rK2C4|0m1!ibHQ+w%sV>$97v=(htw)|@NZXl6S2g*{r_0a`?r|Uh-#@#4xhiAP3~tcdexE53nyR_8nq4Ri z2{t((WROfe;UV~9tP)MJv;qgyb6b{|F7{|vQi7KmHllUHKLvCaktNwkC1_gO7W8%Bbb@Od?yU{)4OO@$T>auK zbBgQ^PK`P0T#hb?={pn4p1_qw7dINsLMm7x#?YTO_nVHMoeae@xb?L>VEUSGo2;(U zLAlbkr^YNv3N!F#!HIVl(-LBkT_8D&9x&JYOjlE%`|d3F-)wr1%K*iKVABr!{S-P_ zIG2Gj)YG?Cy20{fb!rQ)H&Oez=l zfk#3!jX&zLT2sOn-ohs`gD~qb+HUL`5^X0nOf!(NR9n?ih@QOwF9D6)8Jr|bp%c`X zYR4j_HzXy}9l-+V_5MNaW`~GZ6J|*xxO*oO_2zcWwzJ6YPGFcY)GZzW_hHveb?kkM zQ0rPx)a5zxTMXZa#*5Tywua0%Ix-&thIb68%;1yDW#3+aJ|*M=7P8pNXUe%>*pY@1 z09`&a^=nzQuhcP7F81|!>0R6-)6aY#s}lnxRMKD`g@54y_haj&DjDfM83cV?2}M^q2p@aJ5^=Ood{q_zCQTz(l&M;f?$~LO&w)3j6aW zAZqxpYrlY0NJvLNQ{N@&0j@}Rd|^Jm!I^<6oI#zv8j+{0Hw(H9){&$mz;+t}u}M4W z6#ZCl=X+{P*XZ0V64DT%EPr}vqqauA6da-Ss90Z$Cxw-c+U6kn9jw|a8*HJd7YdG@ zwbYo3DSmK0BB(rmL&kjYPi;KZAGRcq7}R>vH+SBnam1A;PFOc8XHcTf5dE_9q0`#M z$FRfB_9|WJ&>CBd)HC@+79pfsDOeoq@m9F*}9B3Sv@n=NGes;1xw-k(u7kL9n zN1m6n+A9I)B+Q2a92u8>F2&wPORh_T0q^|hp6C6NiyfSASZ%P(wm-(TEQgzJ%fQX@ zhQx8n2~kxOs8#f6QPzGt(={~@I8dn2Pc8qfVw33A>IdH&!Tm? z@I{DO*seyGXS?8LdXvqCj8n|Yh6;g@CUcsP_VjDGjWe`j#Xwj5oOXu8-{%dS2(sb{ zZ?;JbJ-ZA3Km#buY@tq8GjxRFTYy+~_bB6)Xd9xS{?RTiDpn$ifk!z=VN;>)a6V$q zVdBnS#dTRN2IE1dJdlL(k!M~aQ2A99)X`HCRq#C2vu4({lc`SlMGLFt5m$wE4gH1BMp~GAf56~c~CfE zMe$zImRDZSV}oPZfBMwO>7{s3x{_U>?K^p5)%&!_J>RHHtOlhmblD7Tyu^PPr}LTr z@cW{X``pC`h)^OiAJ3$$VY!=eNXg$~D^oDHiYlk)r1Pb#GX$bhD80tcdm)48U z7uX*SnvbOKUvYNagwqT~!jEzeUklkEhrDs809X;nM#m-`<&X6qOhnmVP)aa;ZZ00q z`xEowr`SCfK5R%~16|F8P!OO{u)*GX>6AK?_tUmDs7``QCmod`#Ii7F*IQW>;-Mx@ zoFeOky*Gi*%DBWcsvG9I7hu@$c|gYiTQaX)DnUACx^^X1_2;S|h#d5t(s0LPq@d?<*H+%qe+| z*XLWVa8jjl`8W(2JUxRf4by&X<&?Hn_MDc_L7n$Rjb386&JH<}OGiQ&+7wKT4C)VG z_mU%zEpNy+1XIcbV+8d!Sf3`LAjw_HJ!#02HnkmXnTag76Fe|XpF82(BiAqm3NqgF z1oow=7q^+8`GUeP<7H{MDE$aahEfz!lr(2NSz^N9)`Qe zp()7?9C%}E@I-C}7FW1Hb|cJ8JX>BFTO~R2FAH9Nhx|t?zO^2Sgy`j{bE7G9Y@>+Q zK>h(huCf=sjNWo_B)eczcN-7piu4lXmi^)Z-%*m~kf9Tdxh2_6J@XGV+W*A@0=_m| zD2P8g1~tu3$H7$h=_@}50~zT}H&)hXZL4RC)NISfN|O*3if}Vhc72e6A(*E9sHe4P zr+$+zheL}>R#^J_U_5&u)`#HaQ<^tg>QXtv=Q&KV@+B%Z7}aj_B|b2~no-5i6y6%>9{dmb0yL&?-Er&Q8^Tni@sT%Ma$Dn0i7g47yJ=#Uh3Ev?mt@QNz5w_qUy9e9< zYY5D7_S%}u_`@h4h$KA3jjnS`j-IkLr6}Iz1p*zEe#nI?=|@leZ7Mx z)yy)raPVPFUV`IV>uWn}qcp}QL>@OOfvt8J?4`sV8_0PtuDVl#m!Ow+p8g@kXMJPw}|?3R&Mr(g(|?G}$0FsYn|U++g9zD$`lPIOwuN6^}f-}@eg zve_Ntkm-Bq1 zh4Q(Vn|4t%rQF!h8dx;*?M9DKcnD?lXSMgj8hd{{?4Bic$1!LMWFTW!%ozaQ5FoMN zj(&;3Icz=HPZ&ODdZ00;^A$1JE=b=~*SBtVUvVtWM78GjimxkgX%3g zk5yd%bhBRr%nWyC#hK3C&#ijGhafa)z6?-C141@F0~DuHWKV8NfC_A?I{V z3E-}1ZAf(d1PVwU)g+q{vU7o2Pol0XOXaT<&`S)FC7mltzTq-nh!uA+NtnM5smO2_ zOrg~l;};#w&HR{x|CK8Mf-2`Jcyr|+g;;l;f-;XcNo?OiZ(s3)&l!HL+->)ijO)`w z%IswED_jjDzhfV!ZC`LanznTw%9ffe9;3)oIWc@`T3c37(Giijw&Dh5V-r6nCnB1C zMe&gvWXN)0j6}LHtUaXcTc1~lRzk2pYAj|}iC|)!-qukNO1y=c{ zgu=iHBbahYuS3H$$J1hvdXK$k(y`Ued>r~ilMiBeF%SIPvG6Gtzx-~V9(qDfAov38 z5;qD)!`%v&5{nx_bYM}BT|pu;O_U}; zn+j;7>I+(S9NhX##?mrpan3fpZ2X$+p`hU z1H#)`Ud2&=sgG0t+tbn9kXQ6&e=K>r-Tdfy_bm4N!EK4lf@6ePzq|}pQYdKo4hcH$ zG^vwvc4#*S-=AnjF9jCoH9^cnMSHis@FuP_(i>=%3wewastBYGdEZ=*J)x)Smd5}G zY)sH?v?xXU&d}k%$>V!DkThnn{SggynZjss%eGG4F6YY9krjbgbm-hydJ!7&+ z!FwMVdhO%3)}`pSUeBK3RT}2S1t~I_gv0ifJrXMeY|7`U!^k-aL#j{_m6C4^C|=>D zX@(61*8vfo4#}{f>UqFopc6;_aRK00P>!O-BtVb37lI0T<^4Un7H&rj_Qycg#hufD zV-a3w+grzGwYGS!yu5mdU}|jyD)MH@I5b<2e-1JPK(K2pqYPCS0z*SeSufC6qqK=3 zhiJJtcH7bgX()U!Z*WE)=jAj2klAbCXaJt~a~Y_Ohr~x=D4w1uMzo}#TBQQ2z)1(b za)8D_25X1@mi|EnNpg2~{hvlBs)c4xhidUUQpYBmE%bN6o3G$OOpaBOyTa$yq;~1g z+BuxZ7@I?vU%h1aVs?$YH~+O2F$18A(be2x?z@1Lnkli1`?BSIgg8x7a!43ljW@|? zylmvG#N1B=Xo(c)9pD9fG}O@tfDQsK!HR?LPSH)lZf~0ccppnlAPB7-1wn8w)>msa zk_VpiJ-WdMHsuN-7XYk4J#5~ez55UxBDNHmMD+=vM$5_>&W;~+-U1*8&Y(!9rVv(9 z_=RD9%`eM;jM$|H%Ypa?C9RYvd|HOIHg9Nv=ukgJ-*zr}=TETLV{jDJ#SNuEn4yFu z<THKDj#Pd#4dVy4kZ+`NJ^6w9R>2E+L8np&W z<}0B}bPDkxV{61Em34Db4cwB8rP1NNK z$)j_fYXTjD_xf1Kz2T3_OYJjofvny7*?xoIYTtDLd%rx5P3!Ipt|K@adiV9zu`1)6 z+C$+F8~h?9LO6#~oGk6`<#aQG#jgM7e*UBQs&+S4g` zM**}8C$Z^{f$0F|)d<4f57qETK#DB(Q(kl00l9`q2Grd79fW@1eC|)Of!8SpQt*IW z2XSY17obw`gq)IG2V8+_H34KmjJ58?c>sW4+5$~jBb2s$;X0fLf&Zmp(|sAu5nA9O zV>*|M-8wj*AatGX-I*VL1#RhB%Wsv}f}4jyFw@f4138fgP=0IA!TFbzKqu3#C|``i zV|k3twRjmNqNsNh9O$<3u#={zz=MQ*)^S4-!a)oCsQaYJ$&dHq3<2XtV(&2Iz2 zN+9EkL65u`xresji?Q07-~VnT*vGx!1kXM z2_yku6B}OY<=dw)zbXXs!1n^&a9qb}s=UVIv!>@EoOL87aMTREs5Gq!jj<{MTNa!v zYQsoS9E1|K|3X^ZZg~H-H_2KOx!ncHVAZu}RQ`m8L-4w5Yjyt0|8Ob#KinC13m=vz zdqG+lxS946x%6_41rm6}uX7s|VjlGar}pckNLoMw1-5tO=OMN_)rLSu4 z!F9BH?mF_MAKbKko3a5TK@emt26`y`{Q$C3@ED>z!Oj#;!em(#N67-4)t6ky-dcxb z#UE~M4495$r+t}?aY|+3#P~6zshD9gcn*4Jv`Nio%qqS3DHu1R5~{o)h}C_i_n+U< zK)LX5SoG-^fXV@6d5IhrEGX@N(t^=#Qr?@U)f;#I`P=UCum7dT{Qn>hzc#%2HVmLZ zM{i$ndhz#%4JWCw0vyLg8uxmN#v0Vkqns6z4E}9v-^TP0(m-BD|1EIZwx&=Su471Vn1hQ#?r6OTxRaVzZ7@cGn-=JR25_Th6KfmCpYm zMOv<0zz9TdfXN3w-N?hcegeD={OvwcoB$@JG<9R{LQrXf-X)Qe5fmej+0=;E*a~mK z$d0i!11uiD*(s&!?&rbl4bC$lA26?AL6Ssw*BZe!?heC{dBrLm2Hfy0SKovDQKi&} zq&DR;NKXGl>6cMQj*kerG+eMK#`6JP22V}R1kSuL7TfX3Z(d#6+M|z zRmOXbgnQhqv)7qILZA&8=1)BpAb9o%noRXyEPyccF%$~FC&R;gPeXPKU_HNlF<~4c zhJ5rX2rEri^UuvedjfE>prx+epv@7b7C)(uV+jDiuYr%_ zdW55MI}jpd!7g(IyyL|n$+RONl%Z6Dz5!5{KKN=Awlz93QOaAJ5rkuvW}*)sotpqx zmkF4@y};@E1);~`Rh{wu_D&cc(34^nAiwqyzT;Oz`aiAu`4_I1I+P-RERhPq|HKq zNZ1PA*M(Rqgg{zET9%#&M=OKq1B)qDB`<=BYtl|MtbuRO9$H`3E4aQ zh)brr*ld!JA|JH!5GtW z|67E{kPbM#60!*09|!-gAfqw+YyUI-z+CNF{G$Z~5eQX@ncE?n=d8PZ4;5nK}pE652zcwJ^oQ`x1b@2YKOqM{Uq6Vf@(yl?H1=UMZ9E zcL-Q_FPmiKztx%^aq?J8&<{B1|4_7II_C2LSoGBDT;azfQOwQ~Kp+94iW5* zo{|gC@&P!M)tNx33PWs4;NlnB~8LGI0xYu-| zG_{|aJog8AX_4j_?#kEvo6@)4FRJFBw=s+?zXVvl*^)ozmv-mg-IuLk_66CNC$1BzKh8XH29vX%{{ifX@SbeK^RIeP@I)z}g~FzU ztgr=%l#nMIFM(DzDZJkk2S%5KE|3HxY2*`#Q?*cRS^+Wx7OC&JkrwkOx%PV-(}>BJ z@d}#NhW8n?c)LWC%P|mBbELj{1w_`KrRYDk)UdOp(7vU&=Zm88L=(}}s`ukM70a|K zoezKn2O$$l;{DszF&&u2jw}nN`yUko*&Xj>VjNjkGVrSirvDsjL?`U&K{PnWoqH$sw)%zP+0^{{JrPe_6ao1R# zU1g45T9#MjAt+p~IGg;$$J+pshB%#gipQe~W_)CiNbR@P?b%X=X=^csAWDMKYJj zRYGwpIX8TN7a)jIQ8~DLbe`!tyscokA_Mok*yiEt7q-%v@oqfDtgadv2=r|av3GQV z%~Udbe*MLB6qZ%!n=sDVTDFk6H@m5LYR!OL;x9M1^m;E#Ws<&-?+|4wAH69v&yl;0 zWNDP>YAkXe>G)q6k`+0>l1xNh5Sd^CC#QfY$J^S-j^*`+yB~;@L$kZJK{zNPf$=uy z4?CoGlJawfeP^z`egwejA-*2j#`!vugn^%QVuQXhQ#wwpgru(<>{@Ob%h^cTME;m- zPTnIa)NVhn2NxtF!2U_s@#?|g3s2N-Rm1%0{H=mTnJALe#CS3CB`JuaBq9FP+JyyJ~$t+2Eu7II@fi(&r!+3)njekC%Q012`$ve80xX#z!W$ z#-3WAPe-L(m=KPw=DgxO9qeQorLHhjw4^%g#x>#17H{h95d1aLOad+xL|48wN2`M= zp7<$#0-cx;{Q(YSQ5k}L{39$ZGL|+#B;wa2ywvZoSH*-Y`me_n3<`g|(GHWs{5TnV z0%QzcVoGAM_hzmZIWgSow6r6c#R?Y)rvJgXr=M{)kqQPV4Hx#rK6*?DUGc z-`Ad2Q5{=uI5y~;>;L5o&J?1>3c&-=@9q!J+)dESnBaOT_ z^s&6NR`@LLQ-B2Qhy|Im*V{+qTbT|%uwa_b`|=u=7~F$WqeXW0p(breHd%aHtueQf zM^0jH=@G?Egw5d-Lb(*E@?-~G+(@m&gN`8dLu6{qTBQA;y{00k(s+NAbq`?b@y|%r z=UnL@?zc=kSBr7FIgP4(m(!m(BQ;~IO#W#+7_@Q8??w0>*zXmwh0|QElGrV7@)V)z z@aNFx*EVyO*qbEDRAE)dcvKZW5FOBSQ#)HSsT(d)sQBPRrjlNX*BCA$-wo5b_6GET zD*C0sq5~LW1*?Z}+#W+`Ip|&@DUEJI@==zpM3_8jM z!f!xoF3UP9JDfg&<2gCLOtUSfL#=EOV{s;2 zGK!W0skdG}@Sv#T$$Vxv+I+v1xmQg`0A}XonS2fpe!Mx$OptZ7%o2q6ZHBN)5MEbI zpAr+kA=N8}Gm2bs>F|G4$5!FYp@t1sDzlCUUug_ z5%ig)Gs)~XVy(%(#8@PyYraI;C?Gar=B}^FyyE$ng zoqA%Q)}N=LG1FJL+v)>kFWY>}|0>}+m+lZgGX}b5fMB*ICuAQ<#qkbblA!wDkg@P~ zt)vY0C2R2SbNbuAv3@ug3|`6!(sT-T6|$MF@uEJ^&^FIv=IK7(WJQTtccWoFn{b7J zc)l1BzOCU*hN7u8!=nsnkNMDzTeEn%2KMMknGVrGrchn^8?azN>fk?4;K=TS5;Y@1@|h}g#$EEwdi#2! zPfenEL?tsBb+cT{s|)F@TloXyQ*;ZbfIoc*{E!q{__>V?ho?Bp#pmO&#BGE6!vI); zB{xR?xQ?vq71NA{I9!K4L30x{wyrb8jQ~e=gwoIia`TToVhP2ZiyIj@5xIK3=bphV zsp4Fey#Twn;O#f9MX~9(Yc|If))Nqf=;UQ+d(FuAEFm5qu{UBJBl24=IA?*?&Pn4Z zZ*xh;BmAWyNWx5FYwwEl*jyq70R#&H4I+6(|(NFN12_9wqgXH=OxOi$M5wl7V)fbiCYcZtR1Wyi75_s5f; z%=g*91^xnLOh(3H-$F>t+E-C*(cZz_{zg8mA<04mK%od*&T(ldG|6_muikHfUJ1s+ z%=#tuuf5qqQK=(O@B?fu-Q(l(&*SdtpDp!t^(;3yfs8XvIz+^+Av2PmVPFovgHEQ|u&Bf1QJpySf}1spiTt&f~B%1T%i zNZfy1g^D3*-w|!%INM#AY_s>cxL-ia0mt%MasK(D)BF`MGBm?N$0dg$kVI?Mp_9>A zk3s=%je}JQw&bmDfWb4OZt@45t4od_s`(JXxp9Zu{&7+8m~iz~{C*Sq*cFm{Ld|`u z?&pnt59r-+xsel{BZU0b2R^%xH&O#soE+#G6FWN|pZgaJc(wS(ypHa|5$;z4ZwK^_ z-Hn7g*DLqK@g27q&rh)%Ms&gaJ^GtiQ&VhzSZgJJ0kL7S%61ZXmXK#UX7fouvGkCQ z;Qx{t6=$*k?AnK!rWW441K45C-&q7ZyfEX2k1&>?rXFy)cZpv^a@U>2OQt@v48ol2v!c)OTibEO&aV+6!kHn8`Me&XDl33^JxVFY3W%x?^G(Rv?# zgiT6K92l%XL)~x$A-zZ2m3c4n&)X2`SzD+Vu0mUt`_UKRrMcYV^8+?uB_8=2*3tg7 zS1KFEO4bnokk=bF?!>AiE@{elau0y0c-M_A|dLCV!{&4$(EJt77alI;=2~IUy=nqv_-=?qLbd{u+qyc@h3e1U^b%C zzI3sNqARKB!Q-uz63CwI(he~O|AlSSFNyTBX{XHu6cB+=n%ur5$U8GS46P$rAzHpZeYReH$RZdPbEDCf@zMEfk79%a3a9kNr;`&e5k_Ws4z|iU zjDYiRVeDqc?+M)8u@I}=S_dTHi$?SE2WID=Y!A>M#i(RYScwSSBOv-}}Q7d@WQf&wLq7&|hOjHE7qGdwO3xs(@#c%3&j6vZ1$C9YahWNQ)d% zhj=@_PRvqm^rPNr>|p~Z-*|f$Mk4}-1)0UkY#ATH9^#KNR4+j-?bR1=u83E_v1g_f zf07g;q;RJO?<~EedYz(a{a9sK?TwnZ-u4Eb<62VVx=%lO;W|J89E^m1D%!YO5tT?9 zqaRN@{w>8;-w&L=5lP-~(!o%dVcDDavk9nACux*uFuGFcbNC6kB+R#xZsjJJMx;A^ zN%bSZIC>FFYeQ^0D3k#?HO+9DTQ8+cycfov?xUgH9B3HLzrUo6&K}Z#JppRvsE4fDJ+NOrx7YTIH!nu=f_dl{rHgp46(a& zl1iBS%GG9^tXiud@UkS`S%W@;oE*$V;Ww<8jpqcchDh$|Ns_!!NMTJ_=l`46N=cK2 zpm?h}yKkb-FQisyQDTF$qrI&}w0CLG%m;6$5Z2@ z?ravBQUOPaMl$DS!UJY)s~!eao7qyK6Mxkuc2F)N@ zYH}PHEs{_}IgqHA-VuI-DT3`vr<%7!S>Zvazl8F{bRNFC47k78GRfFv-B0#H*Mm+! zC~Kjl+Q6qFmDtX<#%001J16fKdh-qR0_REPDg|?sTYq0dE&Xd`mA$~3iTA;D|0g+x zvo>b;*jouC_vQAud-}!D!Kw$$vvY&r5dv%iNEwzB`99%&dZao%+Q8~otjHMlgd{nu#}7T9SW3mw z7@dTF{jGS7%c6^ZwM+XXWF%GDUlmBu62PXcpnUj04u9F-Fk7b<5*AijSK|*r+rbw6 zyoJ+O!fjE(NEhxlhq~5F9e7OKjYHp!DwqQ{h1sM?1CW*`9?JfQUQR%$T?smZa|0xW z?P}7Bo1+kG(rsA~hska!puF4JyY8^E8Ih)0;L)34lf8R=a4wt^FvpKq%>p=P{XEWH zOUdf2Rgq_sa89=y4OR>bBfYCMixl(7mIh-E>1m--K&8L7{rA&Il%nU4c|pZ1eyOz~ z6FBy~e_WyDO3bls3l#p8T>7avnwn+q`REXY9|T{ntd3?8h<(I)o3)}iZR=;%3o zl>dcog3>zKXakAdIqE0D4&VI-h;dnr+{V$KL8o?+WfTGN`&;0$4t$SI6k;}j#U*e{ z<+|jUEUR#t5s{G9@z}SlCPlo?Pus-kg4=FnFH-Qgj=-YWQ|0+Zq?pyxWV|7HohgXn zRjBW`s*^AWnwdGq*RtZX&#M!OSFx!oM0fHqgx>SmxN=fP5wORkgP?ZmXYaYYg!cTL0Bn=owunt=Ue~JK9Phu_7zv z$4Zf6t?D=RCJ8RjN#G{e*2YU`+I%`u+HZQ+>aiSHtj2DuI7F+}2!q9!)T-p%V%XT> zh)%ZPTPDIvf53ix!gGu#&wXh{f$fVU{+%zuI){I7lJA8QCTeRR|HzCc5!$Ev=U3`_ z(0%iQ?!(~EjLznm(Q_~k3b2qxtZt=-_KbF3pHOjDf6oQJXcrZ zv;Zbhby3H7lfqCpiwZ-IaNF)8(22-r@3DwD zPfsO~WVz=IR7FevmJI%7r@H@7YYqK}&iikM-v8fbB>t})A|CJC8{n1$W~aJP1Jpj? z3dsN&sTS^_G(;Tkt`4l|BECXcS5FSKH8hr3MB?*VLsH=u^qWEycSLPAbF7$v!ZF@wi>3s`L+14bJ(Ak1nVw8OH9>+$x( z970~_^<-qt&bEd46Nse)on2d!2eftef~MyN%w_fnqm`WiSam%r1qmaRd$}tNf*si> z0H@uBoLodqwVM(aLJa>p9XJg)T>5yeuxEY%wSq%fOIFQ`+2LWR7$j&M14D;M41q|3VwPt34zgU2q$i5hh+UBcB<~*^JuUlfGF_mU z>8N`E18sH+Xex+kgy2P!&u8}e0UWDM?J~@N*%B`1KvX_2=CsVZQ}T0s0g%U48m@v2 zDAH5o(M_8jN+zv5#A3IUQH?-seVmQsQ;sz54*V|^=>wqK%fKp*?aOp;2VF@pLIi1@ zYC)9VzbSHVCtDt%0m(}**K2QKOaA~`9WZ_W{u4aT>38dPA#VgNY0s@0Sf0ci0DuyU zL6~V&?-wxK$^vpNudvRV0&y$&1gXCPJ%uy|n$wQ}P9Zge7D{MI#qxb{X?96=JzAfs zTZ5b%t<~YcM;Hk>pMVRTi-idXoMJrPeqfV?t?(68UNX2-jjjThSm)g34hLy=NA0^~ zfVl3u+0}RWKLAstZzj}hW4iE5OGbeKsM`?4c08ljP|YNpGv+EvHwT78L@441orb#U zeZ^459w8A3j{pUIy|2SV_Cqii{M-ZkvwiVCV)pi$<7#~mk#Nr2iyc6Kx<{L^h6zvz zmG(S`587}}s7&|G%eUXmIe4X0|Cl}7;%_+xtq~Fnsf!@^kk%Z$e{RubfX${AuEkq=PFU4>L0n>;5y`^1U8CrWzf%FwQXA9AZ`XwngUPs&t5j^h`>eS{q&YCx&MXrFM*r(lM8ReVAQ_^pDe^;z3w`5wQ zej5q6Ie9P{lEJ6DDQ6kAE$O{Uqr~<344kkgVp0&5bXWj3=2{(r(g?$sh}~8|!k)R) z{m>r3pW8qmRqv8$i_!yIS~w!@-hjjh-onTu42nFo1(}ursSe!N;`hHAi;3jUD$%`Ej;}IfSv6|WVkaAPO^_#@@CIH>9FyFvre)}i5@7y)LRlhq(H5DlUo!9Z z7lOW74p;!)Lh2vVi}UA2zQNnmETdL(1idLc7=B~M4nC8pqL7=>eCo|eQZzX6wrPGl zab4x0xmVyR{ow1(8$!x%x#s9cShe7sZ@G+TxId(J){-S`qY5em zsMK?Z&g|3~fu7!*p9)doYH#zU#^Way#^UqCUUi8#bsN`#k9sj4^d>QB-!mUyhO;2w zW0{HV4bob@RX08k#o2Yf;{}wo3NwC?{nKe+T3&AYRV=3aZS|F&*rUhm7o1L-w$~dCnEFqwHdhYe*j})R` z+hpdtb0KKeT8~F$r^IydsLcD#c5yU;?MRkrz;_R+k5eyE(_?u{M=)A_h!zR&gB#F8c$wPK2k@EKX-auZ=K?v}#d7Bd3US6t1Frw0`S&Eu(L!=|uN~ zVKt^IJO^4|MB33C4sx9YH%{g3Y>b@7JoM7!*G%_**KZ3f34~0X9Fe`W$zi3RYH=ne z(`~McIgSNCQ)-X_&-e50-f5*huPu}ltC`N5-_t3aS7Mtt*dehjY@YGxLOfqXi43XK zsOg%#&oFt}V8iDBXzt9zp?u%|Uy-GhErn7UW9++RO);2Z5L%G3@5);CNRnYJGi1va zgOoj$h_WwPiV9`Pk}X-XWcQqt`hK6|_y2PoKYx9Wk7>-@_jO;_xxC-6llgSQNHD!N z2SzjKd7{17;xr5j63X)wehMzZPgt)YF<=Yb2+O2w%7wmt6Z{jckkcJ8}&3wfHld>2vD;rxUHFSz)?>u?>m1-SobHF8?b z9-F(#Nw_cSmA{1aA@hsbC(vA?dWQIaN8Ue+%l)pLaHzZkbOj#3zE9pAJ`OAT+_T2^ zD=;xs;Bj1jQ~GPf{wHan3IHR-eA+S+CUT%uqjp-Kqqrd;qm1Gk7r>7H(I z#K4_wII7YZ-AzU*NWMF=6eQohFPfu)k$rfEa1Rw8oE`Q2y>SaCF6w!}WLPp%LVz8% z#R1fFUt^B5)!czmnJbk3L!{J0xxFsN03yK)-{{TvEJ|ss_+FN|vJCRUzEb-b>^XfT z@#k?R9|kaRKV3@mm3?$l7-yd*=bo**2D4c;L-I`DoJo%Hnkk?fpo~q1{ZnI7lDH64 zqvY0Iw-@Fx-6?x}i-G#P7js@4t~QgxNCp+c-6`$-Rp`4Xdpu8M=0+=obbSQ*$mpb9 z6bz|TH&y`??kvPvqQ#b&oCtV`EbS|f?*4b@DbB8|o}5qi@Pm*umT9=9#wCpSPss^EMPl=t+gNtixt z0xk>+ByUO%;;WY{DlKq++4Kgx%arcg^QO{g$&E4htTS(cB&guE&~&A2iu(|H;O0ZE zsiXo?s#{SOkqkZy7+y_3B_nZs*&~uN`C9zJ`L-FT{2zP3G~S4SS#Qi0L*8yILZeSx z_i4I7?+yl8pf2C)WMy@FM6vE&p_^<8J`BM+wp(>^KhOO= zgb1{3*f)-&+}E~#eZERtQ2|6zQcm-O1)_Rj1wZuTLl@4|6k%MM>AwJhJ9bk$_uq4# zaGEmO_~!ixn2=aUdCr+tN-fEm=a}qD6+;Csn`}UhG}Htkp4G@)o8$N)qaF^dK0IvZ z+@Ju_n>aWjnOvq;RH6DH`#KuSF7LsAY6gPsKPbUu0~#fg43~8sA-h&Mz3P>xO9S#~ zPoi(OmiTH=!tiV98@&q=L~TGI7=jxIjwQLzN&!_=wr4-X=y6A3-?xeZpmb7aJEhH* zqyw**i%!9-5I~wZXTC81t@*@S5PCe1T zSY6g^TgD+^tc3XH#p$mmuy1U|?AL$VFLwSAxDh!_@6xS62n;T>RS2rbU9{?=cILyK zTUkGGwW66Nx6a-^vjrl5Z9az=ZDCqA57TKM{eqBS4Hte^*Sy>?mh)v{q_8iD(Fa`{ zxwc9KDEz`kXu!2X6FnmdM;D~7R=Akc4IP3;hA2c2)LEmsA*58P(7+4+J34GtW zVB+%o{W5V%^CFYnIBed2<1Gr)Ir;wa>c>-cZ7#F+Z$bNK*9k3F10lU3eXc7_I45sd zOP%rNcErd&v))avilrajj2iMiZ#kU%PkA1in*l9SNYz`JNiZgGtM<@-mGAK``vDv! z2Vk~StHJSvKRXhchdcpuzL=uU7(SewgRRQoV*k6ACv7;k-0O`l*FuM`ykNERj_{X> z^nQCiWF(aN37^{=zg4$14iUR@`-wAa6Fg~~7x#4O0x~)iMBRsgh>%k?ls zkr{TFgj+CNG@2)mpB1Y|^D?d?>ndeagvpa8AU-^y*KnW0Vpz_C1R(g0Ado&m(*yhI z*VQ;<%~{N~^eWGvUp|d<+0!x{Y_)r){Z$%FNr+wT4-Kfh3ZNc4s;?0{lx%YRP8n2R zhvfiE)9CRP!2@(;R;m3AV{{qZmjn>|mn%OQF+BE)y@oryUEBK;+Vt0ZOTNP{G@&a_p1om%m-^^0D*-W>ebdCD^$o?^F9FjrFrK z5CBot54z78FM-=UwlAhn+4VGZLP{w6$arl}s;piHW%O}&3Ds398mfTe>vlhdE3@9}T@d7vMjQ__4t`wX4ZBR-K|@;gh%OoS)s-hBec-N?J& zJLs)DlVbzxJ2d?j#%@9^XRKE~Z!!Adodve0r=eUJa{Y@RG385;B8%ch8v%?AKM^v5XZ_NbP;{(E+N5RxNE zWns|W1FJ@e*1=pn`m6(3$n(A*tfPZ@md22A`^@E~FMmuyBxu*(SxwvHXDt}zU zNjo8c0$G42dXlZLVu(AM{d{b72a=>Ny9or_jN0_Dj2A1j&8q^fi>#1})i$2|9K1C# zu=_NX)MMyOG!d`)osh-K{%4HZ-3HCmL=q`TL`&EF7?u2&7MY1Ho0rTy>cNJojN{ao zKO2cg8{;n-#Mym<(mhPv3Ed1Hx0A;qH^{IO%>rwqZrks-@@VAkFT( z(ij`Zq=G2AaqR@`ssZ$?=_Y37W^7EE+2)NN+~tm}bIW|3%7a~oWufD&;*JycLw&16r=B{H8}`#nn`T6U4ZY0XEo|9TxU5UMUM8c_$N zAg6?{rYAvcz&RNO!!Wh|FWif63Dchx#12WcDG=Rk{Tz$b1=uH+fKg1hg18sD!wuF5 znJ%(wX7RL`sL1r|#S8Ahv; zHsDC0&iRr^iw>Q8^xmQ`O`1R0sP+u$ss#>iw1w1JRpde0pVaSbl3Ok#&y)#48eH|2 zuNAzY5O2CXNb#Ofu{^xe10yHidg@UHu>^AspJ2-L&?~J`)^&^CJUnCgMDV_oQh`G!pA`)7IF?Xq7DR|&PEeo27GvMQD zT_1pL-yezumcq~JxFuzwqB$oI(eM&Qv1}iMa--1f(4>X#McYg0bG8oF3y}N#p6)dW z`%?_nzh~rt{^JthVR}HJ`ysB`{kKdCUmqFsUxhulMI@TzpRE828z*--Wd9$y42){} z#NgSBV^Zz_H>V3_@)sL`!WdDKO`Y}dIwo3aGPNC z3|uL1E-Ac4ur|k99!l0xksuM)81BnWO!y# z--r;bb=cHa`<4F3Rl<$-I;MPkgYMWFr)@buIEA`tJls*Q0%`z|T$l}*O)_7ZP`f7(3BW^XbSRg_ck1!wE*ndrBg8NKM z@bA#p0~_PQHzbtI_a@n$k%f^_a7Se2IJsD*ScW^LG7gWpPM$1?7wc8?3W_=W(J&mz zeDp4h7=p(ytL)%%!yJdZJ<<2tPRqoVWz@BP*MSIn+5=&$@v>K+8&eVMsD%fLV>A^D zKNwE>b?~LjTk__eWqnmFwewGy59uC={_s$9@~wg~4q5Q=t{U&{cwshmE080^!mT+EEgq_WA zBVyok*zrz_J*1)11jDQ8NOo?Ph^D*?u`dP}0?)=YXf3w3Xmii%|Fxdg?4K2ywC3&D z^fN;|smFxk=VxJZm+G_?D!W)H(9bhe4}rIQyXSUe`P<93`3+jR^4W z=qdc9Y!dneI%c_8>WpVx`V?VMLHo}|4P}Tpa?PmoohE*}_EBhGSwfvCyyC<_d+ek@GFiv?o^w^}@!~utCf!W%#x8Nx`@41u8rB zg2bMP{Mp!>T)eFCykf7iIxUWoHn@1{B~(J|PL_*t!#qWM)YU0mO}oNh6lE@7m<*v& z(V#MSl!;pob*Ec;sTF^Zi6eaCpgR_Q2lcc2kjp^gissHz2qoM*f+Eh-kSrOnkLxk_ zm{q(I`JeFPh2E1L;Z+BdcW%DbA?X}va`Z7v-tVyzNx7Hlh#qKH)L=J3nOENV%vF>&z^EjEl}{@g&sDt6^! zoFbUZHNvqpawz#D8DS*mrt`XTLzl3Ji9CX}CK*B-UiAo>!FI6%4Z_}d_??bAsza~+Xm%hDfngMFMrp5 zFvFWVef2m?goM9yr-Q_=&#V!#7s%QE%JX8mmmT$MdM?*!_uG3 zbHwRjCZ>4o@KK4u#J*;ashH|KWRZEh)31MTsC)y%Qu5c;zwGMk#fabTW z{s&1`tQP?dhr07Ikq#quY3<(w;zGQ`mdGI5XlH^u)Q;T4w4886={^Cw(yGE6?^@P}sqpOYN2JL>`?$RgPztcE&w`D1L^ zA55MboU9Aj;^|aF-tX}8p2o@3{=3q_bq~*}&kFIj>u%9@ba1jh(ux*m5>Tn~BRd^- zMe?5S#J?R(jy@kFiarY}CbE~+wR|9KfZFUw#^Vwkm$>V3;aDwyUiAPu|pHZdTIUN>oH&3UC#`&whggm{~Dx56{)4eeh= zdnuo9mYA=%eQ|JG7h1*wGi?E9l8Y7x43aS6sm%SXSquB}N?(8WDDh0%`P&01kr};1 zSD&DNf41=V$4f{z#1slla%IU5{T3yaoTt0|eC3Dowo&F4!8R(4HxNH~;uf%xbUM`4RZr-g-17%XS~#sE~wR)-y@fHWUZjiqhg$`dL3G=?6L zww=}a`u^CWwunS!74>_Iyiya4q@Bd2;rM#5nBjfD_j4oeDgQ*9Gn%raOJ-oNC1F|v5rdyzWG~Ba1m7|y|CfIrsTwV0s=h}I7KvAyl0@M*rm1o zYB;k!c>U?lL&SK}Q|NgeqH*~5h1aKDapeJ*F21EbaGx2GICm7|u43?k9UN!BEhSHaqie1^f5e4p!YS za!<8WtRNT>^Z7*7kXp`cj%3Lm;;*!a@rPr|kb}>#9bDM=Sj8=7kL@)ePw-{hOAj2A zU3e+|_Yo6j(IsNhBrgxbQpc`D^a(B=In>gIXFZZ$qF7xn^>%0SeSLR}pr`(;8kt1- zm?PmNFB&b1N5*2uj;7Bf&1SPuoRjEs*>Ly}O%Qn!_3YbMc9k z#J>mYI3JHlY3yQGBPJ4yGTDj_-PO`EidtNFU=?#sKe=Hdc=HVdy8PpVgx6ijKc@(W zPCDP&j&vmwi(QXu9L?Zu42}w^L(^GjxQv({M|P1P1RTv}Sh{84Lt8~g7q{*NG{c1K zB_#oDZWDRdI?Lk(=jMrKGo71Z4Rz$*-xD!X-20*ylf+j$etmhNV#%w(%zldX9&YCz zc(KBRFY9~0bH0Sc{eia0ioy1Z3T|r7<;LW>E7#n5PUWEvkjuuob(c>kv;4e=`V#}7 z`!KI1B)8Ifve9qKO(+cXOm=T`?Qg}W(o#ezo-akI| zdG2Zy5ntb1%O}u%`BxwuRa;B-2O`4g&jr~;q#6>i*Fzecdl_#fvN-M`kw|(U6J`IN zfpq4)vFjwiFaqu8^3M|qCo$plm=L0~nl!d(DNcWQV;vvqxSBm0YukCs1?Atl6MG;& zBL~v6{A^35C@GO^&QFOebKc_Bm79;0axo?v$Rd-j-&c}qFy_h+ik2Vsn)Zr@ja>+s*U#g~P3Tl< z0ZG_9e3A85(b!rw8q^E-&zSVp{kFZ?WCPm_d6%7IiFrRPUA!_@xG{M19m&!nVnDxT zSBwEYGtCFTb?m|i!{&;|w|r>ZspWTKDdJ<~&65g=hZ3cmB`ygU=(-V98xC6{>*!qF zI!_%&i1IDd6nNW`_^1#3mGo}zn%_d3YILe(>m-8WZtWJ+ zuH8AvE(dMIXpu zWs)#_5=d@3DM{mNRnC+oq`#}cE#+gXY7IZ&z{(s0dacA!8E9wggOmpR#y7#X5r%@wJ&;RYLbX#4jM9{^ zgb}rxt-zwx#)7pq1!C=P~2Ax?H5T+XWWD5D4tm)Ch3LUxO(jkf(|2Wz<1iR~h=fPz=7JMx|AtiiW=UApk=R_=4CfY(WFGasI8AGf;=XRR})m zO5mS@y@DcJX}`=}@B&Pn@QYz*NJ_z#BkTBCxZuwO0v2ikN&E^`0MSrPC~IV~8QHku zFV-y7hzVU4`%t+^K9LY@Q`4Hr|GI_sgL@;1-%h$&?EQw)e!~{!St;)=(b|AV0uxhO zkjzUQ1sTA&GxR3Dl-l17BZ5T>5JkiifI@F&;1z*4{QX`0X$4aX97O`4;ok7wDK21; zSVh)^p8MYW;`+nhaqt84F@G3`Z+KcEI&{Vood{K4N*NhcFwzR9AW^bYI@2BEaM#QO zgsKVxP~sR30(_vq4ejB{f3*P6`Z8U9r>hKvIDYN+JCvc1YdJb84wS^4$P>}vFRAVw zbC!sWd}W!}qrCo@TLh&u9;VbnFXq~LOy_<~3d_(!eFDZ&x>@8sc|)lQ$!kCMP;T;Z zBCfjXG3Ysaq1F~2Lz&V59fT=RL_slN6slp+Kh56WoDJZYgsQ9tcJkBHZlHqpf&}!| zbVCCW1w_`BBz~i7=uU53g-(~o>h=tU4SXmOT+zP)UIxQWD*$%|a~!4yC?jUi5r^i$ zpa+HW>y)PQS6IiV-DzkmjH#2r?w`?=XZ$?O9asqwAi<^Y4v7V=1Z9wMa43`HCRfJs z?smH{oUyYUuThwF7=3m&*${i$9f#y2g^s?mP%j@lfu$dT{w?vgda8dDbLGIO@IZj= zhr#RqEPZAZ6?BUF*)VL~d7S3GBhjozPQ(rj&n@ja!mfSnjPr6*vjr!`e# zCSYbJc)&i;1%L-&HpPSNCmS?xbP|50)1L9rglq`u9lQ?`Nmzb8C7+TTkNXlLC=Ugw z%jo#q&w#(P3dAkFl#y?34}`D0m^b9RWuv+Us>wB)=h>kzhy&Xdc?I-aqphS-3%gErdzu##I z*egqmqHj=ZcSfeHzFbl^-|qNIeN=LxkpC`|lw||MyG#5qEY+C>I8H0@-Q?DfDcowE zwgj$JnDs+pFmmU{G|6P(+LPq~I6*;p0qTNLFB|@YJPrCcxL?!Hp!-c}iO5dxT3i$v+lxg6dN(L-`#jFdHpT%Rqa__~nhOZ=d!S`ULAZg$hc#cYMViwu0DmzzhcjBWA{jsnRqtU$SEFnM^Z`l2yz}W z_iT(s)`k*W(f9A`lDduKYujwrFs#|Bp@{u9K<_0i8*mS0+A4p89QlJma#WLOoX9Ou z0(M~mMJCopvqqQE3d9elbJ1Gu%G-V~C`geiifcIXxRS%F`B>o{5FfQ*HvpD+@Wh#9 z5GjtA+GN6HqAlBRq+@yw;|}H^@uG~uTQ{+EhnojG<7;q({41{^rHYqtWc>1#c!jSs zHWkPel)PPQL8?=VYQ2v1u_3RmA*(_!5UX~dI!?4teuo?Uv^RE_;ycswR^=;kIkfF7 z42i3kW8u=i189bGFBJ~@MWuiqh)I1nZBXW7PR$i5i3cdxsiR}}(xG5Tkxam&^9H6& zMPCNKxY=HB&t|zt)=#BO?)HvtL?SFVrTA%5{g;ibj-*NX!3DbH0-mWa#g-Zc)b}0( z^9$rQPS803n1@)!bO{h*fO*D^Uc+FzRj90Lc8{3kFgR*GBr30aVmsR zS$nSYF=vQ;U(gnPS}a&8(NjG2Upom`;^EWFc}y^6;2&^)hEXJgV2FlUWZuw&K^4P( zKI1$DC&g1|%Yk5(>#Hzlk1BNz;sJ@HKx8!n23&8!m6rv+X>gP$M8H9pgb%JoK09!9 zzf+GL%w+-l8Cbi#0T{JLpr@=G{qRGHcAHjtG)=*q3Y;fd**3CgmR^57e&;CWz2f@{ zObr$JN?T~~F&3jvPc>#vKUU8zA&IPwh2FcK-}|sAA+B_M0`Ft!KHH4Fhxi~bQT_nbgM1dp+aIT?NOlYr< zd2<(eQ-=i;gzu_WsSC#71X=O!=&;g*fmrOjbNJ?flD89*1Dvuk1w_fXN>K=?Do@|& z^J)>u0q7WK{V4E3C|>ITXtV#m(T{4yU7#^|8O=4wGubUy^RWN0cKD9h95;Llj8^XfWO^xUPXGAq^I~w<=2qG!z%4R)0#_#1C zNcirrrtXIGo6ie!z=qO?Gl(c;kq9{-$xD%yf^CLaayzm_G^Rk#nv(SsCFUO=v&>Q< z;y>-p8uL^XZM57){yx- zRxzAM?Z%hb31Z|Osp9gi#m%H6Ssd^3+us+~`{Dm4>|cn8m|eCEI4> z+#%=G(*GTUUolY>twv@BEsB{Bq%`(T*QGu}X=mzpdYn(f4i?rg8}l@qcC{sJiG-;B zEf?BzUveX`iFv;LL1iU8%P)B?)>7D66h4;y$B{w-Z-WNRjd|V_4aV^rj}wu~(?`b{EJ7-4*r z{oS)LLU*<$7Tet|fzk^Pj*X|T*7DJ=38l@|Qy!LJsi&&-MKmkNw4#gsc2=Ce22Q0X z35~j$jH#~})T>AzJ$5qY*KB~|#F)<fCtoLl3?%(-#Iy#2e z&iP!W6_CuW)gaQ3&}KCS-_5-dP z8`eCV2oKt?&Jlb1ycXJ(An^$S*d!0N!x4oVA{4nBc}{kA;{SQsg;*2**9^GwFTD$c88{0I zi;EW@F#r7_9)vb=;z}^OVpCTSB`R7lpT@W62PemWevMwehrl_dopI7YtE+<_!Jvcq z@83>RA2PSF@Yz}zPlPw^OOxRF_b0i+^cnUhKAxDc>n}t8{hyuo{Pk$HM)dgvdnYI9 zf3NHm;IFT%Lu*WE{^w7&qxDdNg7Sirk|kML^>F3>{9tnIZrsQ|$!h-ZqLRCE`TzYv zmOX+k|6Ujz7$*7e-MP~<2><>)-Q)kGmzQc88yjD^5X-z80ANrp9UU12%kc!dhF26j z&%mIzw)TOaIOY>4AZW8fZ}|uHN>If6-oLD5;NQVY0{W)cOrM;)(L}3i2t#v62P8^R z1%WA}P^@YU#q@wN*TTle#=yYf=FKHYUZGMUBZJEU4Dtmhr)e-*L||w!SS*Fs3No#I z`}TRg0(ZifufX}U*|K(Xa|3qY?cSIw4{l2GVP%Cp3U{!rt<4E?tZf(`^UHe<0xni3e`5ub!Ts zkx`=7ARcVWsaTc;vJ0xN6EB+WFQP0@e2b6E%W9kE^5`61rfIC=!i5z`W zQqpNT4(Yvb?Sa#jd-@q_JP_&MtY^DyWo-?Jz$FSeFGW;BVjfsy(g?1*ClgjqiHJmG zFhq#7)Q1KiTxB;c1Su$p^m=ZDV=kkI<6>jc*>TCqelXF{R{7>O*i@t4t!-^#VmzUA z2841J+wh(ko6CB7A6n!DCd9AI4?yDQ6>$13bLI%*DOi7#qNloYIzWr`V`}Pg6Y!rG zzzsCw`L)ngS<;Tpy2fPA672Kq?T0NA@}W+--FAAA!pKu&8JneyHUyoApzz+BxVXb> z$`O*>+;@hBvW~6aTBC$=j*gCa^v_hlA{uC0<*etpkRUPEE403seCMtb3>-ou6{AaF z8F&Iu?FE1Bl%|ahYDCrFe-l>2bdSpMcdhXx(l0RV04MXs>(?8Q|8M}m3+iIiY~BP# z;c4+}#ciQ=9HAHZ4>#~8fKXr zZ#HjLwAVILf?IVmR|3C%EBbM1C)}hZV6v31`^$U;U}G`A%2!K4FsEg@-wX15xY)e~ zMl7s?GHf~^-|$)m5QfX`w6ywn6xQB}yrQD_m>!4&L&Dx0C3d7mge#Nfq9g&8vw3)wGVH#T5XSiNCn#KE2H&T@t_IOLeP*{*6N zYer>xqi>+(xf(PM-J%>eD1MX?oNJ)jbwV9wWvwlIoSogkp-3-#pMgUK(VF_?iQsbd zIrm*}P8=NT{+@DBtNYWP{WpSdNW01;gycTyteBwkv+m31JSLrwWU4)PJs!c@n$t;heHWJlFnp*0!-=s zjwNs1=a?&c;aDv#o*SsLQZ5VZHa;bDD^=REvB+Q(4?TO%nKBkgi9wqo|C(u$uZJ5!)Yf= z_3jIjd)drb><%yOin2j_Xq6(FQw1H0pcH;O8%juk3Neoi{a|)f*lvJ>)p8 zr0v&T(6f3pJt#;qep*&a${jYaUtsW+uugncC^BSCFraDUA7kk%elv^Tu<%g?vm4dpU zN+Prpq!?IcCL|0yZg~~2%8H@Gn&epZkXjhXE zzALqCV`cSzFUF=Roe)}jluJrpK1nfLI_$-c^8;8{oAQ^9fKag^6| z9}H-Aedyogq8xHB7@~$GE%RGKVq(90Cfx&vgHf1JK4>^KTbTR!$S?eY)6?>@WZF^spjAuA(iZEk1N^ zA_jo4nYb$^CeybSX~ny~Ubnp)S#6Q-Zd&Z_pBEa;*1+mJzrRcH9UabTG`Vi`f|pYE zt(McrEN$(^;|}A-KEW}#3K!$PxH zmXM}Esbut#pz{Iexne?~gYLlEpl zl2{!gA8kGpK=jl6=v%zBo_u6YOf2R8H&3oEPr70U)f@sHac^ewlIjEq17yUf0~N0*B@A~2y} z_gumIvZm%f{}3xDaI>#<@gd9$ii)C1H{9GL4SqGp@Nrm8vx+9<|8x-Hp{Jr#kR#03 zRs8_g6&8yfEfzi{tAzkESjN^nI!jaeBmF(RbeAGz-}+~dBX8~P=~cBlD_kRvnR+Ux zFCW?wqIn5tfGpxk2CZzy`ogbD%H=(=V(Xe37Pc=#eWZL(AI9q$cVcCDM7Bz%+i^AX z!g53om;q}b=#BVsOY!rNlSsz^&-<3d>)fOg+5Js-tc(yWbU_(#?gtE)+}`w|S-zCo zduV95>VtgP!9|mG@%T$CKl%rV50rzwtwJ|2A1^f_w2$^`?Ek8fKkq7Y#;I%9r%Q*d z4+|TfeceK$QI7D}Re~c3D|MQfvq9AwjMg8~JrHM;MpfE!-snXyKooot;qb z?b%dQQ=9foJvY(=uyUx=rNSuzyQKT;l=fci&pEKGTW0fhyXhbhL--9BFh)ZqT>%Pv z(0ztbAGE=(XtkPGHf@|ORnSt+)L($s2dPl9_p4`dT5dcsSbx9!tIGAe0aJcob09?0}-%+JZIR))E*&z z@#4khCmgm>_o1uxMcLKtg#J9BCZC@f>)M1ab$dldW+v2M)%}Nx8;?sB93}Rc-3zS3 zaW|OGE=T5V_%o@}3UH^@NC0?NRY+N1UA+Nf$W<(V)p^!O4y~_mfq`~o!%tHCwpoTk z$%Qj7wW%&#nRhftw70Z`MMWvQ4pVncK-@h2UFtZ0BLS$~&`z}-vnJiVL3fiMZlX_g zLs5~F{E?rj2RXbLG>-h*akeSaj#+M};q!u4j znT{l?h2-_;z6@vvg*%6g=oF?m->Q@EdY`^vERe+-WxIRDF=p#aKwz4hn5F9{p3OKH zb)H8`)GQA_c-h5B=V)EivpZp!sVt1>+$_!UeAXia3jpF^Nm8l%Zo~*-)tQ)0u0x>=gATyXOD!na=f;*j*Ixq#@Kp zaW-Vi-EFZO_4Uv`-+;q+(gh`lMdxVe<)fA=k^jOXL|-g^Ba z-521Rkx?;-WoD*-P*Nhfxp@#0Ok%;)j0_dsnV1`@Y8=e;V!XV(>Xh8QJdagWoqUCE z%hZ(RZzX}KrLXtrckpW@^%m8xUEAA9A6~ftsE&q`cT(+2JYbRx|7RCgO$S|| HVixp2f|J&) literal 0 HcmV?d00001 diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png new file mode 100644 index 0000000000000000000000000000000000000000..dc835c86e0f34ca2e0e0a7d5aa9b27823d3a2601 GIT binary patch literal 63741 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

pxCzYCE#>gbN3ij!V5aNdyhLyohUW<@kr0# zZa=U>bS#?PW(;Zxg>vR|Q3j}zeiYGYspKZVpf%DvFv}Wlc}-ns9X{M3@fI#(dKWr& zb?!T}!jiX>!Mrgl%z=Sb6PhQ!mK|%B85mB_%wNvtKo(AYRQW7s_#;`zkfLcGA@|6P z>jHj&Z>PlYbtbqO650}Ef|Ybg$u?3O7Y0RkIlSP8q_`@?jQjn02%nvjisIXFf$)+I!nWhJKG%7Ri;y-t&sB1&Yf&u3@ZqhSBcLeesIKW>4a?1KeCuzy0@GKIef2W#PXllXXGG63 zHGRLoHs{+MZOADr>jTsx-e(jW-X^s_WU)JgJj$8J=2wtwbgny6{M3y4WQ&@c^n~10 z;F3Ta7n6HF(O5;BRz(MGt6hUyiqGV$or#T6BOLuNg9=U28?H2m8%q-u$)y18ozJ>6 zUHF2KnD1G@9UEy&&$&p4XA8PO`vUc?S~G1Y6gZn+igh3wbmWVMWuQVw|k5=IG~KRY^%GNyH+)?c67RI`<>fgiBDR%iIF2H631 zj>=34FUcCB&pIT{Lc{3^m1x5b+Cxq9b-zg%A@BY5!Ai12FAG5~g?yVw*%L;b1iImO zPQH4x)-V@7O-mVhg!Kn-K=&h-c08uT#9!W`euKZwl&2G^2aAg?bIE6&D<+OO`FcSa zl12BzTO#YP4+mF26FTp&6aqHKExnCK#vuITkA>@5zc8VhKFbA_N*n z14kA>IbOScMSG*rovMSaiFfA2I<0D7g4~+%1!sfJr@so5WLx*B{~*PxQcSGb z>=qy@OuaOl$$#+X`c$-mFpLD@9VipTX-8V7De=EP**{9GZTS4=%dpT}-nJ92@VY4? zebaBt>e z;+Ajly492Iog_Tt4KJDYfJt9bAo;q(XQQzzv~+Pusq+oynYf$#p)MP|f=DF5h5<}{ z&wYQ&J#IYKtG+3y1FM9Fj+6QhcGFfh|0DB*TXja!cX)@sO%X+W^Hm|AXjsHLG1j#) zyrK;z(@yK{O6_RA4&&R9(?Rs=r@!jYx@H#g_n{wS-ukt)M^yYsbkDdNHFQYoQhF;| zk2<3won`VPx1^@w62}j#FFypjPls+WU~>|xw)RQAZQW2AY^DCQy=FSwwrxz2`Jnbr zwKovR2jZ`B*ajJ_Xvx?j9@@)4fp@NWim@v{U?NPQQ}H1U0QcW+Tt=`oA3eaj=?Lgv z+2TM`hON-IQ~|>`MJ5E)la7N*?mre_t20c$e@JQSMmX2~U9smW`gUZTRp^f+$95Wb z4$ebF=xlZky;dVr$I-Z?Eb`SKuJvJ03!{tQbIn^E!H>c!O?4QB2#B6n%rPJU$nG{T zQTpE5!J3rJ2YGcBN}AcIKS?Gy!}Y(UcI{uxn>JZqomp;gaDrSU6ZPK*9dtYhSzs7OzhkXwr`4(vpf*M zLl@-AMAiN23H!R{2Pt&Zq(AkDq+s>RI1T3;yQJZ!%A(HU)}!3ghbJL;oeKKW_wMC#=5UZ)0(wxX4gkJosYdYH*ols`Vk( zOAy%6Q(9r8V3%h(pvoaK3CxNR!KuPZfdC=PFu)p(C5W%&@>A~<>mh1 z#$A4t>}CU-CCpJcTUc4UptSfL{yCN>o7ZgcENsxu!{v?pRrNol^%HOJd^>n@Y5Zvv z8*66y4N6eeyb+e*`?@&oSO6|4bZlm>xxrqc10v}30P&p`OA_XQ^9>8*o9h(xe0s;p6=mwjwl7!y z$1k@6>}6C?DCl{v*}L2)j?no~S3~9>)}ghdN-3wg5SJokXAt!xPT-B=v+bkA-`ii> z#Pk}jBxfCD7M-1%1X&s0VkpF^JDC!Vad4y(2pLgge?nkLk0b6Hui9Tuwlh&BM{S3m zaGM!LL%06N{G{J<+(hBGS&8(VrRURsY*6-?KUiMuNjoM?y6Z>8xZ?9EFU#*>k#%1~ z>j$5T#^+jLPJH~iv-qEdnC*lqt)5s#tba<@W$a5_yCq3}d|q9l&TO=5<@6KW(GHZn z-?8?5J)wq*TuGkd=xzPMY>W!e3hvJ%(@zwp{%5~%7W=Buj!iJDEPqdZoaE^+6_Xjq zK6AOAzv_YJ=sO~{^)6BA0Z!yuy{YHmqMgU5x0!XdGWSF4uRnJ08}K{7{-Kaz^DWmu zfBgBf9&-#*kA;6P} zEOn;c#^X~+fON3lF7lne6rgvFZCFXfb9}WQ*qfnLN z)|$WDaM*2hw@482(SwhSY7S9xf}sAPkh%mc;lSj$?=5!1+O`B+&HA@<(0bN_lvh~O zB*{3~|JX3K9)cr~lQ-6&MDM520v=7c`5IhMZCiH%pJ@W%R@kT`DuMI40UdO2eL=EX z?Woxy=wkn+7r=bs)WR|TFd?cLQ1|gGgRm4hvz8%VTgqer-SF5znRWp`3=tawoj;ih zagE;)m=z~s4e_GoB!@FBQ#?#uK)`7by&hymiqj)t=oH|;y`YtYy(}p0=?`$waYHb! zip0gxkUPYH2EyxUy*L5z?8#${`LhIWPB$2l#^Co+uA5IFxkZ0%ApU?k9*%2QME%BN zh&g`=;Ndq|ZlY8I03B&XVr-$48PcNn5p)%F0eqZKhv5m0sLsy002D@^BS0gnuCHKi z98VX|z`}Re&-=Ic-?Lj5m7sq9ZzcdYX!q3c<7)HLl6+oPjhTGtulv2qtKPSZ(_%WG zoej7z?`x9^p&n@`z;YgK!cvcS3K<{x5|p=eL9medjHlzSwfyb~4U9|t%|pm`FD|vs z`*Q3z!c>MJc|^Ck^{(`4kK_5mHv9Cao9*1e+F+XdwlI}9TxGz*BQe2=iT#aGiFB6@ zvAw$Ob+SNeY8^N^GI?_=awt23@drY=~AiQ@YzBz(~ z1fbW>{5N1ZCfRx}fwcw747XS%fkxV|fIze1C+niaT~?N;0_@W<0L@`_zWmm8%A?=C zy@##`p;$Eh8F^x(=$75E3pVzE#xJsm-)0oYU|jq77%QAiSHq=scqw(n5o$&7Vg|DG?@NcSGmlF~p)lo{=H47r#t5L}CkJZx$mWE|tEE-qf+H4!D z-J;M^uT#ZhBZ6|G3XIq#5Jw9AKv9Z({pHIP;K-LC(OtM~_im#N`Xo;)VfxcQVIP3G zNJ`!yTaQo>8&gY>wCmJ48z0XXJ!A*LAh+bLc3zdnadL5YoJJuA&YLaTl^ouPPyVo= z)O9MaO*#Dxdb4K&zOjdC2*lEKx~I)g`J_N*!s z&c}KK8H`Ad_Uen@g~YtRHDVT5JH}GQBpCCA?vhDF;3iGs?5FQvVuFXyW z3Z#)s95Io62KNWBKIYAo(M8R!=f}Go>{|RXvA8*994yJUsJIv}GaxB}A?z$?`L;>8riu zWKtk)6|xx0FS4y@&@@|WIIcld^YCp@WK!qj4kEb_3Pl1>F2z8m|B1b$3<7+RgpwXV z1fb>oC5_5U>U}kW>0G6U?~qfG&aauC7*`sDS_Xo9$Uber5o$2*x{~73ci&ke|7BF6 zKH+MQgFH$bYh63}LZ1-(X7!jY4)O7rY1&xTgBMXQV8n39lTEG;_QHHUJofY6eh;X0 z-x|hh{QiVwqawBmRKKgKPMl{Q+^-MJtY}b6UqZ_tesb_MuHF71gRS6r+CShJR1dy` zgE`P}qo+7cZ!^Vl;9>rgGiB@rJne8<8U6XQYngY=m0c^isiJUYd(ggkzF5$%#XJKK&YMJ6{)XKeC&wvl250cNS1j9RqmA6Wp(N5dOO712e1FStw6XW+=L zdpY$9ZWxB>F)xk57BV^%b!NMXbkM&TG4PQR(3jm0=y_*zyhRWe0tm^iw;DBv#)OIa zn^dZYLO-bXq(x5PjPDl_vhjB82=~$Wf))0g3F%OedzkfmqnL#cQA+tKI?vmJz!EPfVkO82;2=&jw&lS zS8$c*4{;pj?R6e3sxpX@QH?1lxrYr$d?xqq$=Ffwo^P$O&+YZxPp{6`8c^;Y-FO2@ zKV+hl0V}|TWZ)yYBir*Y-#6a+`F;WpvsIls*H+)0NI2pm+(IL)n*Bc(!E^H7sm7UL zmG#o;9n&vV!$t!CM3q&qWQDL|wlspT0s9B=`AooRFT@oNxIgDn>=VVD>UAF3aKd<> z(mmKH-C*H%fql8~EGj`Bdzg`^4Erwgvk1yuiu5GxcUmTt8tzuqvH~y8wwXA_=doQ} zYJ8@2Co-h4>gKT6n??#1uQ{SyQRFf)p3%SMFi1JV>y2xGF~N$u9mnDOQ2{~)JAQX* zzNQkM^@cD(|AXKw<&2#30uodfassdO(^YQ4s7xweldyQpq8s72B&MGyo(KW<;~Gw%y4jx-!Im=y-xS(X4seY z@a=Y$wp|c7>Pz%>9yhZN0dst$F1)a5um7tf)DV~C)0B5d6MXwV*F#=So`)L8VTflu zhG)aVBy5$`gLhm<#XV0cR<4mFCMD;{pCFwmbz4|48L$t5=0mE{_OT2m0737e^h3CPlqHdA*ZiWt4o*qh*-wIESB|xv<;igTJ^ATv8>3H? zE+F)~Yl+m4+yePNx~6V}27($i-oA+BTNpY!(=Tk+ko`QWVwZ?aeiRUykq_9RM*l$% zx`4n9BoqeX_^p>#&Y>nS^BQ&y&SiAxSCS?F#R7Uf5oeb(DZ3;em4YiUFXcRS3#gwH z&XS&;k<%3UD=r%P;|nxA3nz-Oy987z8Ho|-=6!bqB1vXT9BkmmJ9B`pNykyJ&W$?N z6c9N6gvViA2=lADR@=!X-R(!kr=iW6GjNcH4>ZU(3-7g%s3hLbD29^nqDSwE_3rA8 zk@u(lVNPCxQbeP^bMM1mwCNCoSIs2v?XToq&8H2;AadVf^$P7$C#CYcS*pJh`pqZY zvN`CwyUb%%D{RfjCAM3k-{_tZVLXT;SaBcqWm;qu9y5yNbW59wuN$*+HZ{%r`q#ze z5Rg*;OaAW!{xok1LWtZhJh|w)sC&3ue0Q;5(pr-?uR3==?z%dbbZ$_$*@wd0^zzR= zI%FGb5r(VA@|UqUbf4-MF|=bQy~KBVv@K(7XT+9q#=tvx=t=LgKJs~{+Ro5Jt!_Fu zneJA}^(SQG#F_(ss`}~!k{C{D_02k`*S#nd_a7XXzL~dtx5#|w{7-;qG<{Z2HJdE9 zYTF_U;^Yjzi`B1taB|?B-bLzj>iUnqi4NxhZrFbo&|u+z9jPtUA^)vlF^Kp#|By#6 z6S1y5Pu;MFpZn9S2JM$F24l}$^OF4YPQ%r(Dy2|n(iOQ8WtKF3hkv) zN&FVaT9uHOsQLM&7z6zyxFErV5?A&Gs!%{0s_1_2hdeY5Dz#`qi%WHTTnc=FUXPPS z-i|e-qC&eXcB+_UIRAuE`uO2<>+p8sQQ*sA)y8!*lQ*Kr zcc$u&U_Ok!peXDDJLO+614509Kdh~Nu<~0KF2H){4to?V^amjIaatehTx@_CTYESn zKUYn^2GFC@{Wd#0`JI=6UG;r^xM?n3KUAo03HPli03An=;%dJKSvNVI zyyE@9D1nr3AQ-z09HFo#UvCb=M?_2cNE3a<+28%NzhTtEdms`*lJ}H_mq@Dwb1be= z-VMM+N=R~&6oe-LdpHAJK^IbanJ3F_rrw*Z!|=QOvKVwfA}k9+xQN>;n1}(v0R%)` zwl`@a19xFOl7J(w`OP(uP3m-w{eA&488hIRBEcOHKjlb@{U&cT4=Hj?gH~>$FsiZ% z@F^X#r()ll%4FSR5PnlI4pZxmYyCo=C<+LPdsjb@z6ubLC#YXr;|I_ITQ#C8@ z>?nd2_(C$>bJqj2GPu_2-H*1h#TmM&hN%2Qk-ScL&49u@dXHV_Jy~tXN9y6;E4=cX zGu&rt=DYV_$V@^aFb?WL^WjxCa=+UP7zKM`lGjL+56m!n9=0QzBVmf;7ZmjQ_%_t| zNJQSmoCdY@4I^>-ZDdhGyz@2J3}@rg{sM&0%w_cYz?`?;h?6*}0nK8#DCsQvJo&CM zj;TOz3Een>zJ)YO_9FMmLxSh3buds~!V`$MomAqNy4Ii74wI$)wRo#G)56MB1=`!* zaP>5&Y!2jfgIxB+S_v!u{w-mc2|@hw4fuvMn9Pq`%afVuT?sGvF5uB*_hqidacQZ@ z3HijMjO|hq909G<&;5{~yw?%1D*Arrg7F9My31WWjOfZgAgmFC)}qPS!M#)vPNOS6 zMe~w;vEd~{wBqV`2od0kVu8419ElLvH(jtX@~t2&RW0skMIlQp?`49*zn^24n=1o&yNOaZe?Geet^Z zAS{9g(!G84gMAY4eL<|943VjRrvToJzw~`okbIAziz1~~bpH*OFZwx*?@E<*{*G}D zp*%zk;mlasPfecx1d7Q21#M5?togBi!wL+uNfO@T6Y#)iiC#)=Nl}*U!W#_b?<>w_ z?O?ts2V71KYit@qqJI;`U|lgE2Jbe*I1b6{Y9*V52OTnR$@*^>!_O}OD4NNe)pgF1Pf(5MhpWMeE4n-69%E07l+cIrYwA|W|B@zd=#uRT}mU>drzCG5VbuNed3 z0v@oMDpEi6I_P9HRZgIfzIq-AwMX_4)yj6p<5QB^kEKcc&nLLdpmXJu2*$K{UEf_u zJ|K@Gmiai^09mILVsiRXD8 z+L8H@T4VL|+Z30Cx4s|GMpwv`*T}7wx507gI1k_xG`Mp}R4& z+f~2(M@Oax3)R`=bD39wjlnGaNuiOoeWTgugOARHW3&N4%WcZ!MnK^Ed&E(y^P!HI3dpOk* zoS0OmI)oP>Hl(A)bbuw!t?jyIf2apf%Hw^BZzdk+#+a+RL5_tSL`G;2y@hYzKCyZ+t zUns~!nwE>}u_bCky>KY$7T}S7B|)FP*@Ag!_!kiXJ4@UiUZ-17ES=SrEf}4hv0xdk z@n24+JL99;6Lhw~+Y>;4?i73^LJPlt#Q?MeChzwQZsY z)QOD{&9WF#61DY$!9EL2fbG-{c+KRa=R@~LHs#`IABV+;w^266t^Oq1VNU9A85Xv)qoO%)ih1rF~h zg9a}Tp(-&vx=A9ME}&%%k};kNWla`bgdWU*nD>s*s2J%2D3FXVHX zMD*k6kE`wZ%i?P*zYD|nad5-j7UD8s%0#4nW2QeMC#Cj72*x5GtP{!1E^GHk7C1nn zf9V6Y?c3&Uwv_Kn7_Lf>u1X$41_Ju#zH&Y}jJrPMOkfqO3gpkVyMAdOf@2T+KlCKUR>~(jPT|NIP>scF zJ7+M`pd>A)Zo$>hoq45fg_~>sPJ$f63P8uE} zIp{|+Vz+$*ky$z&g@UN!R~m}}RgZksMzWW-_s~A5=*Pi|0Mi}i^N#H_Zhch!RY2KH z^28TDV;vLc!v-)K(2qVlUu8Mc>zPIHX}^=8b4QPv)H47utqB0uC3^A) zusR_dVZ*kt#xv|s^Mc9~l0FK3kJwv0oi<1NE3GD%;k-Eq>_r0hjz82z+D*NAm}9=U zvf>5U!?=tkc02E1ZUL1L={qk-N#mK@)A^K9vDPLq*~Ou!xVsjBWS-RU-yx%m+&R5m zYA--nG@8q5@}JK~W9l(#6_81bvCq|;A7Wd=C}b$QuXWr3Su<;LeXqjsktiNIU7^&l zH!cGDwuc)duK*4K;PpCccdvD;Z0p^|;_dz{EFFv^39y%q|KL~w4$#z$ea*jLF`qAV z4=}UXwOJ%X2WUY42rLKCP~3S@O*lv(cYCWbplQgP>H^7e2RlZ7t%x|KqUYCX;IYC_ z+rp#vB!@rJol*)J>P5T{vtWEzE%L76p}|DT$&V^W&f|6?+Ef3JRC8DqjEn0?IyW@9 z^b64++;ZRK$quu#iUb0BU@&V4IMQ~Q+^I~zVvq;yszqcJEIs73L&Ek8n&odrEGEWb zh>%L$(DE`K!JlxVrSC~sTO*~U%yaDvq5tDL8DM?*YAzh&duU{*s_=Wx$9Bdl;y)_jWh}P63UCq8Drl zFz_ecZEwkx4}ScBu0^zD0vW{rB1A5BUiq(K*E_z@HIj=%Qf*Hx9ZlrbtTzh+Jc@a# z9|rFOP9i0?j|^)pK)Blv4qm}}vTAVo`)(?1*Tsfg<10o^|~ zw+^&quWR1X&;Fx{Ad>k%#%=yb(2)NJs8X`_`AGT$G-u)!Hi&|r8EYng`Ah@FD%i3;NBA*U|s;+W}CLJlVUVL)6_dj06MY`L-GS?pbjI?H-_hq&+UN- zm7tB)y)tk^RhsqF4^}~b5LQKlbQIuD5U<;_B?lGKyV0zkx`8V394abGKrp3hX z1Jcg`TVF+?ZnV?OZtJA`3{rny^`-j~1xCv$4|KFwXgKQ?cOtb?Y8AJciSp}MEVYk2J@3m08Aj)e$I~8@0PRP3LIuoZ|riE zi1>R3BwZdw^wV-^mj_3}5$X+4%@zuJ?0G=D0+lOld-s6XVQzVRvlkR9IMd)Lyn55T z7OAH^RCCl}<96FKQn*7EHu(}{9E;b!pTZFUp=4+UgzB~x51z!vT>8N=T zVh*Y~Zcm=TPjOP1lOntg$ zPtAD+$Bal`lmHL12CFt>l@jUi*#of0v`x+d>GyCW(m4rc$X)F3weW7UIXEW83E=RE zc7h}JeFtFeu!1=ND3&Znfzp1-rBe&c2v?*qtz21J8na%fy`od!#1xDv*wPCJrj-_M zfp|6W!A(UDaZO4KtkO})rq6!^^Z(HE^%AjJQ0619yn^xoo&&5-r-`A-;5(l-H#K<63-uucmWv4 zdRz3pZ*A>-C_BZ}5+0KK1ddOV3{`?#8PR!6jZYP9R6rZ#&@V&YuM*vityEa)6cJ3L z`mFri(R$mJ;iQd+^mZj>T~ z!=n%~pXl-V<+naYM?z48v>ImaaQC|2O~pVBi3E{F6QIN~o(7WR=TEO5yaSLkX+`pb z8%W&%Lk=MukVe0jnMRfM^ULxQJkdU6f;4m=R@Eq38zi#6m+sT|sc4#k5RSHi2@ioR zY+1+t!ZUgk=7>9Af1|do?-F!WZ?4G2hTjxTP`+8AqpBLcB%gfG%H3)lB4Kc@_B!$W z4s)qiu%G!^9k6vB9z5wEj9-^cLhcu(U?L!q4JAGa12Las{u73CAFK9hTC;Q5i?&iB zsILFD!8ujl>b&tlSmwDI-_#oWJ;yFOR0P|0sJL0miYZ)={S*7c;8x{Wx{sD-d0*qOm9T;Ug#>VcjO3vjrf+6}^u*GBRkOzW99z>cV!m z^q5`b$^OcMk$%y*asWZFwv<$~eGF)3$x3A`?-vi$QI;knM})y1!fO{dLpFICpyApV zBGAcDZ-8dLLC_}q7U+k!8G50V{Lw+dyZT=|GIPTxD1$Bl!QQ-7H0n|TAUESeu6-at zd&!5=h~5vYe+FQkjkjpd{Jnh$@al2pu1NP!BK(d?;A{Q7R$*EA3tz;T?>Wzq=4~j( z(K%N|54Ec`715iJfY1tjt|a%>uF=`+4C`RL(5HG7Z^!PEs-nF)^inynToQvmqXd7{ zkmWo(O3bSRoj>6)`1Ik%!eQJ(d!(rZD(bIe=;D@nbT5#yiw1uZzDB8EV>JeDn>$WG zw4Ser{!>yVDaN2>=JD)1d3^OeZrN1%{kB&4`OJ z5~)F}w;!!rVxN6~%$Sd{nQ=3Zm?(D|bdu=haW6%G;C+Z1rgVrdXZ9^J1xnW+3?xH%r;tUxzw4Qu zCwWbSOf)t*YGjl=a6s_Bik}yuF~0#=Vp!7oIU#AXM{3)_N+F<~PCJ)ow*xdg`!{Az z3QDu4P=0=S-fK7Wm|KU^zp6e3ol$6?{$61m8|Fyflbp zld!U3q#mHTAGr_c?pOm`wswzJk573F^)HV#mz2tZ!vEMLu!qT(Q2n z>sCRgFO=a+ZsQV6JpO>z^vA&ic98uJXg4 z#voFF-G5tO;>P>St1L0SAup+;-z_mBv8gkFi&N^fevT5a#%SZwns`e0osAwZXcXU~ zXkr0r^RNhu>=uim8kQyvl)^6(dGJ|IMEDK=_z#W)cdBE+w*;AH!U3FEU3;?@4#ayv4>{!W12am4YCzr7( zoo0KLoHQGiJzp|Dhw9E9LWhz~mZH#QZhTBvIbToUh{kKuBkxXEE;@(e(jr`=yN*kT zUL}>2Rcogs;#2;1Z*Uo{Dxv*!_R51>gtaq>H#*ezJKWl#Q-1;P?yq^m6w>&(c38B8 zFnlnVZbRbyw-nXaPoM@_yw@6n5(_*{km-$O8T;ab#zYpw?BW6xxP#eF8uWZ*G%|bUQCSTGhltzBK9dWr9y?*@_Q!xcTnV-;l;Ka(W9r*{t&VzI+7WgC3rNjr&dtV zpGe@EC0s92Vu5_{e%9ya*FqX$7%fK7UrEbcF~#-F1v!?QTRdl~jA4dgMUBtYf{O=i z^gP`^*^J^$_`>lUg|COUUyunB{4!1+rFA=H%i%S?X!S}r!7EvSoSdXoITFr1(fZBA4v=2rv~t-3hu`Gzc1 z<5$&WN&Bxx)@LpfGDKUF{qr**RjEF6d+&_KJ3VEn@;pyD<#21luoN)xF|WY;E~Z;~ z4dzo?vWyKn{n4o5)fK9oR$tx>C_kBMl6vwfu;Cf{?c%-4XLGtDnDftN-9e)9Fc!sY zLtuB3$cw_=EZeMbQ<-bUhDDA4Rv1akIJfBSs97k=xhvl!kVRH~S3tV_SKoF^4K0-& z%4VwEP3&pVSP4L_3aU&hL(@EHBN)y80(E4zJt;>mX*FJbbE0h^myUch#->SPp`{rn z>J6BFTf?6V_6u>w*e!?PQwtIPc)$=M4D3O)6r>24OVn6ezQcY8(a4D&S?Vr2sT_K$ zkyL`c5jR6f?SDRuV^<>dKZ%p2cb=3TF>-?}28xo*#g&VJFM4zY?W&$o7dToElL*{B zFESl(&?~A>;+!)-MOP9FZS`!t&pfc4r;vVU)FkS6NNO}8sXF2EHZ8_A<-dS5Rl!cg z_?_|XfPZ*^TraDzP7@^h#xAX3yu!%X zm%7OMSz^eW^Jc7iqPu!_J=o@)V0*tkG&j_`*Js1t2rflE2yTn+rr}@Tc?tvSk#I1u29?$@m zDTz5fXS{t35a*J>S-;;YTz{J6JiR8 zHHC%=8(C@rvQS#B^C!I*?6r-PcMf0OkEfWHjDL=I7pAJd8l%S2?Ct{1M#A_N-141L zPoHWZ^3E#zDtqaWU-!vGRMC6C z6ImI=qIAb>hT?v{)cYE;C+XwwxS!~NY+xInmoQN#4;O10RiV+t0Bf-4^#K>;V?>w0 z@SCC6v76YrPRFkaMc%fBx>8I5TggzJcA-^k8gkh3T>rWVLkci+Opg(>eoh^U-tUkQ zJm|ZsY`bA;OBd4*0#IZEX!5aO;W?B)5d5_cgG<}{oHPbzBNm>pI)g-Rf0L;$2n-xX z4Ox-5;`F$qaA!@0fHP)Jaxz!FR(@3CCWUR5f8(%mK&TBv`-sGcc$PadsIRMGK?_+i z{_>WJPmQKAp(^Xg86QT+i^#ktcr zrUobsA4hqR<%B8R!2g?>?(GIz?pp<|ZArdigbFnDxg!kTF-rLyCv4|_bAttHd6}0y z$G?jh%55hnL_bhq=U#)7Ms9zzj$J5wCZ6vzp5HZ1(wX&pH3WT&BHAMH$XDA}_=r7D zr1?kOpE$QpGX~7BiUi4krLVGxaAKBBy4@fPBA|FKFPwTmJZ-efhUI%7gDC7{syox) zVvism9nA>WMcT%e+}U)Gn$c^%o-^^v0(c>5Q?_Hw=ExU$2X3j$dEaNN_)O)ZJKa@f zCNwTrd9~K+YDOQuaegUvWMs_R(m@CFft|gkyt>?V{o=feI9BC*2G4s>e9&Uds9XWwnSEs8+!mta zP+A1K-DdEeE!5kyzk0d=#0mnd}uv(GU#4VATyO7EMOci7M)%j5)%)-T?vCMI;*|ATV=cUNf&M7Olj4Wntk2k81b#8U$ zib@NM>Km@vDad}FBTTO{n&ct6X__pyw3Nyvje$oRHyas!CaJFGkNu`Di;#XJb_4t5 zzD-}uvkNqiIBHolwU?S~(mta{YO2gxPe{Zbu(x}vr-=H@HJROa!UuI>wPx(r`f{2!y$WC_ z(*Cqc^ZW8SQ)vR~Nd&zZEjD|?C$13f606MN9pq2>ql8zateN&7Id0ti9rQDeokg*O z6!Qy$L(>zf-lQP^ribdHn4%I}2WqM2x#k6=@z5yx1DohZemC7M3-$oTdfD=2^#n9k z5Bd|K3rT6*?AzMHC!ZcOr5mK#FMbvRs z9xQJCldQIcr~DnHh4gp-BOG+|Ssi))UmN28SL(k1aUEjbFkorn|1tN`M-1hwqBZJT zHu_=6{ugqWv-nk5Vb9Dpg)C=#-kdS2mOT?##yP&`WUA3djZSibJ}%kz`{cN|(}2k|M;UQi4kDEjFC zg2};}kL#Sb-t$}bChbSs%?wHa9MKj|^=}0=2a#bBZ(Jk$5Y`g@Q8Q0W*|GXhM zuX}G;7V*I3C|y7GW&X}ynI))hz?-98I`RjkbJ494TtIz~6smuOQVUADF!P~&A`DSC zB#&)?ED~@P;9tK&))Xn-0tUVLX9*%z@TM>?9Yz@5#&EIj@>kc;Vp5xlKFg|N=~t-9 z^ot9zEX`glf>xO(p0%3{nDBA!DiDmu^NrAvc1H!4>%d#}cCl z0D0phQ;WC(dw}`Eb(3^lrqls!{e6r{KrLi7%1xsH0U1*B=o!w0-(Y-(mm?y(0#9eu zL1*YKVm?!I?~rGz?I;Urk^er`;tBBJ*|Om#Tvq8d!WZBpjT-ylofQ#yK3UzrejD$p zBG171D_cRIo1Q3W%2M*WMl$+~$jsnI%IjY-`O6M}bfpuzEWUSo1Zc`$YCR3@`LTB6 zFrAu(*=)FuP|}Ejw)GZwYi%U`N~Bzk?s*o@zN8ogL*PC(i?1NTienr$!nrTX&vhj(`4dE6c=J&lql zii=;O!mJiv62=PUx^Pg^ju_BIRL=v1$z9k_W~4`EBg-f6SU&t9U+)=m_N2_-hDD=L zIzE@W9d+;N-xDl+aoy(l%OpqSWRoOS_AnK8 zZ^?K=UUOulWp{1%F}X6`AD4utb@)~}{AXWznpOy^<*ri*<~m_3JM|(oiS@h`3-f_A zSI!8|C#jpj_Iwy`5tmZWiM!9LC*nz}T`gjrKYJ6uw-=Mg?v1SV^rb+aGdiRXu z``I+enT&evsB3S}JzY}h+wy-bpr3eE)_$JZ?dDn}W-lGxF(SAz$d$L^co`MIAW}NT z%)iuBF{JK%)TT4FGpO1zHf@p*hiR*ew=%i~CtHF}!_(ni9i3;`k5ySKPQEyqeS(l$ zDH_#Z5^*?8>;Rv+fQ?@I@nr4gSiXA{sZXk-OZ!k+K`;kntwPV1QCudPK1;ajfLNX=lGb*Mp z?&Wb?6`mTNbX@WA;W_Q_VCQLiB0mKc(>yv@gzMYTY8`wJ9evH&1{+kc!SD%3aYb8pw#KAXzcauUnYHe*APU zjYlRCLnxdB&mOwFdpc{ezqrnf1*aD%{r7*ksju2AFGg4Dh>XhXvkPfFlkVWGM{aCN z^>KG^=N=yY7pEA?y|ELWOL$l5+#Tv)=!{&p)!(Tj7AC3Pn)7>O61EMPSjIhi*Lm;_LU9yaeOKv$iPo3tes^ZQnttj2ADm#j^=c-}Qt;|W3=5@N_l5hcg zMUa)9N{#3En@Sa8bY4d9)zvGUa|OP%QCzQgcH9*A13L4k)c%ZJtA5Tt6}j`X&xqLK zinuo&ai3l+WH%I!HurCe^;}jMYU>vC0`Up?!`QKO$Db42#fJlyZ{U8kR@M;Fkzf4S zc~rFM)uhT=LeZy?&0B@!keF8=T+neQ34+G0tX{CIcRW4&_JH2Snco=G`IB1r;$s>5 zA9UxKdtA;`s3A+AE2cSU-3n#BmOIxj3T)GMu$~z{+dlIgZ&bna=sL7PW3d8DG?y;g z`_Z<^ss_*TGO9~0rKw9Xw{P3|h~U8u9B1eV#qSwX3s4acdc3r7>`)WGv5ia2 zm*j7bDlB83tfDdxng6=&5I!F}EqWm6>EiWF2&vD7xq-h~yZj5JZqS_+!k) zqzhs^^}gDGhr{`TR$ErfS<2EilT|AlwB-@3B)_gPOwQn^<_m3Siu=^W!Tej%18umw zay+a3_%>MVfJwo$bD;__Y;3x0{Kdl`4%(ERZ%ITRF`ZYO4Svv%P;v`n(5w-k4KD6 zMP*LYnJcK7ZH^bqe5&%;hZdhMx2_~j+^Z^k(uHt?aFt4GGj~G0CP`s~aW7_5qK{e# ziXE2*!_$s?ECWX)M9XgAp0<_C9!fpFKrWO~C?f=3x*Nu`a#cR5lgZrQ5sJ8krdkOm zxvJgy<>)43|B2*emOPT2J*>e$TKQ0|uiE8Ln~a$aS*fhkn>r*Xtl}s!!>`8j()O)- zwBUFeyFnG5C7aA9_>J&^CG@peSJl-0l42h=B2`4ymzGO5x8 z9irxzhX%iO6PK9eG_@+eBOc4Y&^#n|37gs~CAISNGZx#hAjsO)oJwPGR$W+Fy8eNj zwKS>ZW(I4{QjD+$tDEQZCoZE;>bvBN3S_IL)tQ3tI=%LSN6N1f2bAKMb^?61AB%1E zlpUrj9)m1A)32u5SNBd>4W~&cKM+cuEL?A3ol2u7zNOACy%?E)WKA~9xW@QiqTa71 zSEpC)As^!F4OP)@IN5-d6g1Is>JE=trjfW>TT@#`G`OH|z(P`(H z{5f5-{qYbSGOQ9^T+6KXj7j+15PtmtmVa*Af{?gs`rS}ptIviu#=SYV<)l8?|gi;oDGOFbw zAKiAlqMRHNQR`X3&qJ7BuT?QPX+3sqOSD#_h8pt=c>`SzNyQiRj^9XHlxgSaw6cPe z=Koa7iv^Jv7)i5w+24`lgB%5$r2_#b(uj}25*^3KQif9w1Sjk|bCeg++y(DN+bf#9 z&8bLTIAouf88c~?McgjX%2Kn4IAZT14NpZ)7|y38@Ff?pdUvW;JR?M|e|@Ii3`X=CkOZ_d>xVjZ9C zI9<_^Omeg}@B|370{k)HK1?3D4-3q8*=QiiuLCI#rn*#>Q2nYFatcQ`A?aYR6SvVp zS~aqJsQ8w{l-2MmD`|-na*8_;#>oa*#Q>Vv366`_A`bD0r&0uH+BE14l2}?NiibgE zvC$}K^1jYlYaLg>W*}~)UL;1Ti#$>Y?M0#F8Un=f9aIi?r72wXYjmb&y(02z`#k9< zBQEtL6FG6mIeq$oDUKARN0*?T$h-L_?b)rWX_f`6-JD<85fKZ8*gu6IML?qb;g%k~ zAEF4a1AvS6-2+DC3zU)jDKFV!A85&g57`piWKPh@qLF=e{r6WycS_BL@Dd5T>okM6 z%XA8=%=6mPew%S^k2II8W3M=o5l+Gyt<@Zy1!$-R)bHw>z{B_x0Qu{)!(U!|HiAXx zN`GAfYAC^OM2+m==W~$TqF3}Sh88|H(CWR)s>isbpmrf}YWSE4BKowdIXl0nyV6nC z&g6EUk(v1sN^+ybwAvZ31%>#SJrco?`m*nG2P(Wl^KbTE`q2qibZH(?ZKxPt06(!> z(Wn&sy=ZU@R4SYX%>v(0F!!V0F5rRo{_|@Vd{II0_uyJvaXlY&JVc12&H#1>B(@{j zX!9f!2)XW94_GP`reI*Go(EgPA80gu{QxMifV}Gg;^r$36%t5LZ8&I_3`l@bLs{<) zU}8awvT4G_)!{c(z^IZIx%4epCwRIaH4yFDq>NX`X4DeyCBN#kQ{gGBFWbnamIqnv z-qzuzB$5Bee3wwMN?Z4_0=^u7jOfV2uK2;T5`?CG=dEk;u4m6@gL~k2EW` zigu7lsQ^nH+AYo)RiO$fWoS$SD9&3IJHcd5;zKDLiO`^pCCq{;&5(_hD0m&!ieDii zC{Xs8@XN)f*L#9&tzxYSmqC*icorPO6U~s46fpMVFB<#-limbG zF`uVFC?HG6c{OiN=755BHwY!xj6n+F?N>UGP)d!qR7q42ms>x{1noP$ApbNAf(qWo z2!IcG2YMYyKk0>dp56E3LqDT!cKU{fKz#Wd;JtY;7`}QBg5=+OKc+iaE%OCTfWk(_ zPtB;(38g!7Vvjxo_0nNcneF-HL+Nz|N!ni@MU0-VC#>&n`i5VhxGsD7Gg%uY?Ju5` zU22uZ!iBaEi>1mNCMc&09x|;CVg>~M+uncehTxAKzKmHp^k!lnU;1w9wXNpwk;`nD zm9KQ%KZK+1tJUWMKxfz54GO&wQ2D4qlO4!7g1!Wbk6Ml}KL7pL=vA#qHia&(`vb=> zWL@ZmNF@&j5bCE2-Ioh>89xEY15GyD1a{1Ln|iRt@VPT~-*DcQVA5C6a=X9p4UDK( zIeB$xm|=b^#4e!7f&QUYp!m}TTd&;SGd+$W{?6RsQ`#k-z6Yhn>4epE8xKsA^DAt9 ziprFK4nFCsWk7mDl_BwXBAUF3ySNR+ergQLxZslyybC)7=;amQs13Wew+>!s} zJQ(;(HlZz)uKAsNJ*;;5KvR(p^fffRunZ8bea)jcpn2vHXmIQ@e~|(Fhws{beZ)r+ zKrg5~DpUU|=CLrmHr*=5-F2qsZO2m;krXj*Z)d3-2?!}AaoaJnA$#y=tyViZaJXe$ORAoIr{Oa7Lynf&P?cENPm6fW|SN+tUh7y!H(9p>bl3&)>Zbf@%g5{2R z5xE@a{p0zD<&ldDp(l$koY5O6r9*&IZytlkXxP>b=-9TTGgsoSn=*2dHun+(+B0^O zpQ93l=N~>7*02Fh!cN*CvU&}U>+4`DjAoja0CEZIEV}Z-?gTiQ8{BPj*8ZTpWlVXq ztlM)3Rq_|y#PbRF9JmsPLrmf>279ZgTAZ%>X)^NCk<{q*hHEoI6*RasJ&bBKU$$%& z-vWQ|SQiuSf2f5Vr>=LpkjX3njVnNvz&!ZBLz~50@YkW8M;~k%w^shfg?%$)o&+Dv z>rKEUbir&NpNCz594fg&A~3Rd<-4Q$M=tI!oGM?j3Fs&o2%e^f2E5K>-6zT<(QWpV z?CQuj3y}}LJO1j_?y|%&lx4Cj=Z7w;miyH6N{`CVDeWF1t50>Wsf*BPqn+p?RLd{R z_+5#C*_Ry{I}Y+npR`YypIz;Iw0!_LI=w;48uo6D&VlJMN)no^^0#1(eor`Hg&Vy( zKUDgEy*XSp$_Lh`R?9=F zq<=&#Iu)pNkrINE)1?aG(zJH+nj9KqV&8qDz+UGWrk`d6EC??SH@B+Rhr$_cO2 zcdmV7y}aiqn!Skj0Dv88KP2q{bo&xeRC8YyK5P#?il1tM)R)kEptGD2#kW>-D|G(| z)^D^8Hk^jg!$HsjNCddBlIpJ@Rv4d$M#!LA?1HGQhDo8?UD>VBbK%|e`Y$+ma%;8P zF0_roiMN*xAc)a$(0yp*RR!P!KzS~sXsU@&C~PYv7+KRGz?-#mWDH-`C3!S0U?=ii zvP@=XuQRG|?j~$2Vct2^=2W!ETWP@i6zp>za7Re-b1S*h1&6c8b!a3SIHz<6S~|;< z*i+M8-Z*b%l~NG2{v}MCHrZwAPr)(7&ULPFR~66=Xy3fPR@8H@PP|9^soa40~Sh~zb2SgbkF7M@lFI?^O&?|3tbdOoc3!iwzzl5ZSRLv-uRh;ipll$01 zyM1c2OU3ICOo_^H?E7wVK<`?l_?l;^9119{GW%*BAttG$<<^0H-ozBBQrV7L<1<%- zvoYMpeI9vpn(u4AN2_zw^$SP@-tB{|%mXZSV3;t zH0_3pJMr1ib5y)J)_T3YgSx9k8wGa{3qKYT3=OGS&S%)Lt~<7>`dj%{3Gb%k?9&I! zp@xt8px$i6-x}>zEaOaP@*O=a{hC@o=}--9ypLyeNM?t5Pn`Yf)V5t2D=T_A+gwZj z`GY$FFB9E#i^~zZlS_FiibSlBdBSF3EBvXt?qNq8I_&@{8X{N7zLekPsmgLZM$DxL zSnhdkBqHja0cEeh^Y4{zx;5owOj4UNN*W1my|1TN^MEp|X)tubyU0W)FphR#`5C!H z@e-ZjnQEKsL=4QoYp$<#rEYNLg2BGtuW~bf8GRZvJzV};msBW-k~5~?_C4Fy-68*$X~X#_wg7rX~4&{r!LK8*6rm&T2~r8 zf+_pZxjD0hZf%C)!Kh7GeqUC#UShvbaJjgc-YQcJKTNKaKQfu+)px(t75dy_pE6HwS#f5!(#pSMP(HjHPzsM> zeVAVP-b#&)7Q9tQ7V=pRvLD@S?}tQL2=)0H#;*RXFAN?p83*cTqXrBQdMWE;+?D9kTv ztC7ftKT_K7Cr|_rzojh9YD%}Hmb_WQbn3QY!{mIV`UdWH;qTAC`O(bVEw2BfJi}f4 z?(@N)Xi8o5H%vkp5({o!LlS7^XnF7@@2+sgkr>@f+|bykDUaY&yMH)_SU;Te8+d){ zGf6lo37e*M{-3~^-Rg5n2fKWX+_XE1OW+fJEi(p3Dhf1`EB|IOr4Z|Z<;Q1AI3*2b z?s8Mstv@+JWLa-lYsBSSUGM5&q7Ww!AJ~V~@ud?~i<8)4n0OWHowM)NRJwCk&B?#c ztHBd-jR7y$mLor3CbqvQ93cv(boU*-ky+@1!c3${Bf|%UbvYkMruF~u!{rf+=<3VG z7ecru#}^e5`E0NILz~H@cBT9IKLWREhCMc*bu$b@?$VNOi({55P~9^>+=6=tZpkXj zBYSphBn&v#Yn`k+s7PeYbeT;?D5$WEowLk)3`ER>_U~_!vef1s%3ZW+^_khBF=B=u zHpZaRgUxRL8VFndKT|jPDrl+?Z1p?cO5H~g@PoL%&KcG5#v_nD31I?G`_^f>>4Ss} z&V5uK6{Q3r^3-?N@Fj!iCrWMzn#eexi7W$b?KjH>)WIPtf4(`(`@dN)(vMa}5+fpc zQIXxH7D==xc6QnNB_4ll;ZER$Fa>r*Fl+vwd&Ja@Q@KTliw5iO>`9F#2JZFw!-bw8 z$4h-5{g1N5uTwFuQtV<*xNhe_qow_4KS=`ohTc%r%DTnFv6&rzsTAO>IL$W*!T*h- zHLVfuH^$Jebcw$eC@oti5afrRhVH`4M9`DKie*ih5cyBHV)nzoEVD4P#lJLlzwsY3 C#p+1_ literal 0 HcmV?d00001 diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png new file mode 100644 index 0000000000000000000000000000000000000000..623f653e1a9e72a72f89db6669c272ba6e03495e GIT binary patch literal 61169 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 literal 0 HcmV?d00001 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a46c0f89fa0..0bbdef5fbad 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1072,6 +1072,8 @@ export const de: Record = { "usage.col.cacheHits": "Cache-Treffer", "usage.col.cacheWrites": "Cache-Schreibvorgänge", "usage.col.cacheHitRate": "Trefferquote", + "usage.cacheHitRate.partial": "Gemittelt über {measured} von insgesamt {total} Eingabe-Tokens; für die übrigen Anfragen wurden keine Cache-Details gemeldet.", + "usage.cacheHitRate.unmeasured": "Für keine Anfrage in dieser Zeile wurden Cache-Details gemeldet, daher kann keine durchschnittliche Trefferquote berechnet werden.", "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "API-Listenpreis", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 061019df6c3..929680d998a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1125,6 +1125,8 @@ export const en = { "usage.col.cacheHits": "Cache hits", "usage.col.cacheWrites": "Cache writes", "usage.col.cacheHitRate": "Hit rate", + "usage.cacheHitRate.partial": "Averaged over {measured} of {total} input tokens; the remaining requests reported no cache detail.", + "usage.cacheHitRate.unmeasured": "No request for this row reported cache detail, so there is no hit rate to average.", "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "API list-price", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b2e00370208..4b14727a4da 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1102,6 +1102,8 @@ export const fr: Record = { "usage.col.cacheHits": "Lectures du cache", "usage.col.cacheWrites": "Écritures dans le cache", "usage.col.cacheHitRate": "Taux de succès du cache", + "usage.cacheHitRate.partial": "Moyenne calculée sur {measured} des {total} jetons d’entrée ; les autres requêtes n’ont fourni aucun détail sur le cache.", + "usage.cacheHitRate.unmeasured": "Aucune requête de cette ligne n’a fourni de détails sur le cache ; il n’y a donc pas de taux de succès moyen à calculer.", "usage.unavailable": "—", "usage.col.tokens": "Jetons", "usage.col.apiListPrice": "Tarif catalogue API", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7ab8054d2ed..f82461e98ad 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1037,6 +1037,8 @@ export const ja: Record = { "usage.col.cacheHits": "キャッシュヒット", "usage.col.cacheWrites": "キャッシュ書き込み", "usage.col.cacheHitRate": "ヒット率", + "usage.cacheHitRate.partial": "入力トークン全体 {total} のうち {measured} を対象に平均しています。残りのリクエストではキャッシュの詳細が報告されていません。", + "usage.cacheHitRate.unmeasured": "この行のリクエストはいずれもキャッシュの詳細を報告していないため、平均ヒット率はありません。", "usage.unavailable": "—", "usage.col.tokens": "トークン", "usage.col.apiListPrice": "API 定価", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 31d5c4af19c..24ce4e2fd53 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1106,6 +1106,8 @@ export const ko: Record = { "usage.col.cacheHits": "캐시 히트", "usage.col.cacheWrites": "캐시 쓰기", "usage.col.cacheHitRate": "히트율", + "usage.cacheHitRate.partial": "전체 입력 토큰 {total} 중 {measured}에 대해 계산한 평균입니다. 나머지 요청에는 캐시 세부 정보가 없습니다.", + "usage.cacheHitRate.unmeasured": "이 행의 요청에는 모두 캐시 세부 정보가 없어 평균 히트율을 계산할 수 없습니다.", "usage.unavailable": "—", "usage.col.tokens": "토큰", "usage.col.apiListPrice": "API 정가", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9a0f4fc0920..6dbc8823189 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1093,6 +1093,8 @@ export const ru: Record = { "usage.col.cacheHits": "Попадания в кэш", "usage.col.cacheWrites": "Записи в кэш", "usage.col.cacheHitRate": "Доля попаданий", + "usage.cacheHitRate.partial": "Среднее рассчитано по {measured} из {total} входных токенов; для остальных запросов сведения о кэше не поступили.", + "usage.cacheHitRate.unmeasured": "Ни один запрос в этой строке не содержал сведений о кэше, поэтому среднюю долю попаданий рассчитать нельзя.", "usage.unavailable": "—", "usage.col.tokens": "Токены", "usage.col.apiListPrice": "Прайс-лист API", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f8a086ac3ab..9a986e74565 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1112,6 +1112,8 @@ export const tr: Record = { "usage.col.cacheHits": "Önbellek isabetleri", "usage.col.cacheWrites": "Önbellek yazma", "usage.col.cacheHitRate": "İsabet oranı", + "usage.cacheHitRate.partial": "Toplam {total} girdi jetonunun {measured} kadarı üzerinden ortalama hesaplandı; kalan isteklerde önbellek ayrıntısı bildirilmedi.", + "usage.cacheHitRate.unmeasured": "Bu satırdaki hiçbir istek önbellek ayrıntısı bildirmediği için hesaplanabilecek bir ortalama isabet oranı yok.", "usage.unavailable": "—", "usage.col.tokens": "Jetonlar", "usage.col.apiListPrice": "API liste fiyatı", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index f9eabd994c7..4f88f218a56 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1095,6 +1095,8 @@ export const vi: Record = { "usage.col.cacheHits": "Lượt truy cập cache", "usage.col.cacheWrites": "Lần ghi cache", "usage.col.cacheHitRate": "Tỷ lệ truy cập cache", + "usage.cacheHitRate.partial": "Giá trị trung bình được tính dựa trên {measured} trong tổng số {total} token đầu vào; các yêu cầu còn lại không cung cấp thông tin chi tiết về cache.", + "usage.cacheHitRate.unmeasured": "Không có yêu cầu nào trong hàng này cung cấp thông tin chi tiết về cache, nên không thể tính tỷ lệ truy cập cache trung bình.", "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "Giá niêm yết API", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 25abb6135b4..5cff65720cd 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -901,6 +901,8 @@ export const zhTW: Record = { "usage.col.cacheHits": "快取命中", "usage.col.cacheWrites": "快取寫入", "usage.col.cacheHitRate": "命中率", + "usage.cacheHitRate.partial": "全部 {total} 個輸入 Token 中,有 {measured} 個提供了快取明細,命中率取這些資料的平均值;其餘請求未回報快取明細。", + "usage.cacheHitRate.unmeasured": "此資料列的所有請求都未回報快取明細,因此無法計算平均命中率。", "usage.unavailable": "—", "usage.col.tokens": "Token 數", "usage.col.apiListPrice": "API 標價", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cf4b6e94055..37432f6380e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1087,6 +1087,8 @@ export const zh: Record = { "usage.col.cacheHits": "缓存命中", "usage.col.cacheWrites": "缓存写入", "usage.col.cacheHitRate": "命中率", + "usage.cacheHitRate.partial": "全部 {total} 个输入 Token 中,有 {measured} 个提供了缓存明细,命中率取这些数据的平均值;其余请求未报告缓存明细。", + "usage.cacheHitRate.unmeasured": "此行没有任何请求报告缓存明细,因此无法计算平均命中率。", "usage.unavailable": "—", "usage.col.tokens": "Token 数", "usage.col.apiListPrice": "API 标价", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index db1390ae9ee..b645b362b40 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -135,6 +135,12 @@ type UsageCostRow = Pick — {excludedRequests > 0 && ( - {excludedCaption} + {excludedCaption} )} ); @@ -161,7 +167,7 @@ function UsageListPrice({ row, locale, t }: { row: UsageCostRow; locale: Locale; <> {formatUsdEstimate(row.estimatedCostUsd ?? 0, locale)} {excludedRequests > 0 && ( - {excludedCaption} + {excludedCaption} )} ); @@ -177,6 +183,27 @@ function formatOptionalPct(value: number | null | undefined, unavailable: string return typeof value === "number" && Number.isFinite(value) ? formatPct(value) : unavailable; } +/** + * Why a row's hit rate covers less than its input, or why it has none at all. + * + * The rate is an average over the input tokens whose cache detail was actually reported, so a + * provider that reports reads and never reports writes still has one. Only a row where nothing + * reported cache detail has nothing to average, and that is the row that shows an em dash. + */ +function cacheHitRateTitle(model: UsageModel, locale: Locale, t: TFn): string | undefined { + if (typeof model.cacheHitRate !== "number" || !Number.isFinite(model.cacheHitRate)) { + return t("usage.cacheHitRate.unmeasured"); + } + const observed = model.cacheObservedInputTokens; + if (typeof observed !== "number" || !Number.isFinite(observed) || observed >= model.inputTokens) { + return undefined; + } + return t("usage.cacheHitRate.partial", { + measured: formatTokens(observed, locale), + total: formatTokens(model.inputTokens, locale), + }); +} + // Stable per-model bar color: hash the provider/model id to a hue so the same model keeps its color // across days and renders. Saturation/lightness are fixed for a cohesive palette on the dark chart. function modelColor(model: string, provider: string): string { @@ -724,11 +751,19 @@ function UsageModelsTable({ ); const table = (

- + {/* + Identity, then the three figures a reader compares models on, then the detail behind + them. The pair in front is also the pair the stylesheet pins while the rest scrolls + sideways, so their position here is load-bearing rather than cosmetic. + */} +
+ + + @@ -736,35 +771,41 @@ function UsageModelsTable({ - - - - {models.map(model => ( - - - - - - - - - - - - - - - ))} + {models.map(model => { + const providerName = formatProviderDisplayName(model.provider, t); + const cacheCoverage = cacheHitRateTitle(model, locale, t); + return ( + + {/* Both pinned columns are width-capped, so carry the full value in a tooltip. */} + + + + + + + + + + + + {/* + The summary already averages only the input tokens whose cache detail was + reported, so whatever number it returns has a basis. Suppressing it unless that + basis covered the row's whole input is what hid a measured hit rate behind an em + dash for every provider that leaves some requests unreported; the coverage is a + note on the cell now, not a gate. + */} + + + ); + })}
{t("logs.col.model")} {t("logs.col.provider")}{t("usage.col.share")}{t("usage.col.tokens")}{t("usage.col.apiListPrice")} {t("usage.col.requests")} {t("usage.col.measured")} {t("usage.col.inputTokens")}{t("usage.col.cacheHits")} {t("usage.col.cacheWrites")} {t("usage.col.cacheHitRate")}{t("usage.col.tokens")}{t("usage.col.apiListPrice")}{t("usage.col.share")}
{modelLabel(model.model)}{formatProviderDisplayName(model.provider, t)}{model.requests}{model.measuredRequests}{formatTokens(model.inputTokens, locale)}{formatTokens(model.outputTokens, locale)}{formatOptionalTokens(model.cacheReadInputTokens ?? model.cachedInputTokens, locale, unavailable)}{formatOptionalTokens(model.cacheCreationInputTokens, locale, unavailable)}{formatOptionalPct( - typeof model.cacheObservedInputTokens === "number" - && Number.isFinite(model.cacheObservedInputTokens) - && model.cacheObservedInputTokens >= model.inputTokens - ? model.cacheHitRate - : null, - unavailable, - )}{formatTokens(model.totalTokens, locale)}
{modelLabel(model.model)}{providerName}
{formatTokens(model.totalTokens, locale)}{model.requests}{model.measuredRequests}{formatTokens(model.inputTokens, locale)}{formatTokens(model.outputTokens, locale)}{formatOptionalTokens(model.cacheReadInputTokens ?? model.cachedInputTokens, locale, unavailable)}{formatOptionalTokens(model.cacheCreationInputTokens, locale, unavailable)} + {formatOptionalPct(model.cacheHitRate, unavailable)} + {/* A `title` reaches a pointer and nothing else, so the sentence is also read. */} + {cacheCoverage !== undefined && {cacheCoverage}} +

{t("usage.cost.disclaimer")}

diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index 6f8d4a2af74..91485e1a217 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -301,3 +301,119 @@ .usage-companion-control > .usage-segmented { width: 100%; } .usage-companion-control > .usage-segmented .usage-segmented-btn { flex: 1 1 0; min-width: 0; padding-inline: 6px; } } + +/* ── Models table ─────────────────────────────────────── */ + +/* + `.tbl` is `width: 100%`, which divides the shell's width across this table's twelve columns + until an eight-digit token total folds onto a second line. Give the table the width its content + asks for and let the shell scroll sideways instead; `.tbl-wrap` is already `overflow-x: auto`, + so `min-width: 100%` is what keeps the table filling a shell wide enough to hold it. + + Every selector here is doubled as `.tbl.usage-models-tbl`, which is not decoration. This file is + `@import`ed from the top of `styles.css`, so the whole of `styles.css` cascades after it: a bare + `.usage-models-tbl { width: max-content }` ties `.tbl { width: 100% }` on specificity and loses + on source order, and the squeeze comes straight back. The rules that already lived in this file + buy the same margin with a descendant `.usw-section` prefix. +*/ +.tbl.usage-models-tbl { + --usage-models-model-col: 14rem; + --usage-models-provider-col: 11rem; + width: max-content; + min-width: 100%; +} + +.tbl.usage-models-tbl th, +.tbl.usage-models-tbl td { + white-space: nowrap; +} + +/* The share bar carries no text, so its column has no intrinsic width to size to. */ +.tbl.usage-models-tbl th:nth-child(3), +.tbl.usage-models-tbl td:nth-child(3) { + min-width: 9rem; +} + +/* + Model and provider stay legible while the numbers scroll under them. The scrollport is + `.tbl-wrap`, whose own `var(--space-3)` padding scrolls with the content, so — the same trick + the sticky header above plays with `top` — each offset is one padding step negative and the + stuck cell repaints that strip with its own background. The widths are fixed because the second + column's offset is the first column's width, and `box-sizing: border-box` is global, so the + declared width is the rendered width. A value too long for its column clips and keeps its full + text in the cell's `title`. +*/ +.tbl.usage-models-tbl th:nth-child(1), +.tbl.usage-models-tbl td:nth-child(1), +.tbl.usage-models-tbl th:nth-child(2), +.tbl.usage-models-tbl td:nth-child(2) { + position: sticky; + z-index: 2; + background: var(--surface); + overflow: hidden; + text-overflow: ellipsis; +} + +.tbl.usage-models-tbl th:nth-child(1), +.tbl.usage-models-tbl td:nth-child(1) { + left: calc(-1 * var(--space-3)); + width: var(--usage-models-model-col); + min-width: var(--usage-models-model-col); + max-width: var(--usage-models-model-col); +} + +.tbl.usage-models-tbl th:nth-child(2), +.tbl.usage-models-tbl td:nth-child(2) { + left: calc(var(--usage-models-model-col) - var(--space-3)); + width: var(--usage-models-provider-col); + min-width: var(--usage-models-provider-col); + max-width: var(--usage-models-provider-col); +} + +/* + Seam between the pinned pair and the scrolling columns, on the body rows only: the sticky + header cells already carry a `box-shadow` that repaints the header strip, and a second + declaration here would replace it rather than add to it. +*/ +.tbl.usage-models-tbl tbody td:nth-child(2) { + box-shadow: inset -1px 0 0 var(--border-soft); +} + +/* + A pinned cell has to stay opaque on hover. Two separate reasons, and missing either one lets + the scrolled columns read straight through the pinned pair: `.tbl tbody tr:hover td` outranks + the opaque fill above on its own, and `--hover` is a 3% overlay rather than a colour, so + assigning it alone replaces the surface with something almost entirely transparent. Paint the + overlay as a layer over the surface instead of in place of it. +*/ +.tbl.usage-models-tbl tbody tr:hover td:nth-child(1), +.tbl.usage-models-tbl tbody tr:hover td:nth-child(2) { + background: linear-gradient(var(--hover), var(--hover)), var(--surface); +} + +/* The corner cells sit above both the scrolling body and the rest of the sticky header row. */ +.tbl.usage-models-tbl thead th:nth-child(1), +.tbl.usage-models-tbl thead th:nth-child(2) { + z-index: 3; +} + +/* The exclusion caption is a line under the amount, never a wrap of the same line. */ +.usage-cost-note { + display: block; +} + +/* Narrower than this, two pinned columns cost more reading room than scrolling the table does. */ +@media (max-width: 720px) { + .tbl.usage-models-tbl th:nth-child(1), + .tbl.usage-models-tbl td:nth-child(1), + .tbl.usage-models-tbl th:nth-child(2), + .tbl.usage-models-tbl td:nth-child(2) { + position: static; + width: auto; + min-width: 0; + max-width: none; + } + .tbl.usage-models-tbl tbody td:nth-child(2) { + box-shadow: none; + } +} diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 47cb31202ff..80bdbf23714 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -124,21 +124,39 @@ test("Usage model table renders cache breakdown and marks unavailable telemetry" expect(table).not.toBeNull(); // Header labels come from the catalog the page renders, so a copy change stays a // one-place edit and this case keeps asserting the column ORDER it cares about -- - // the five cache columns sitting between Measured and Tokens. + // identity, then the three comparison figures, then the per-request detail with the + // five cache columns last. expect([...table!.querySelectorAll("thead th")].map(cell => cell.textContent?.trim())).toEqual([ - "logs.col.model", "logs.col.provider", "usage.col.requests", "usage.col.measured", + "logs.col.model", "logs.col.provider", "usage.col.share", "usage.col.tokens", + "usage.col.apiListPrice", "usage.col.requests", "usage.col.measured", "usage.col.inputTokens", "usage.col.outputTokens", "usage.col.cacheHits", - "usage.col.cacheWrites", "usage.col.cacheHitRate", "usage.col.tokens", - "usage.col.apiListPrice", "usage.col.share", + "usage.col.cacheWrites", "usage.col.cacheHitRate", ].map(key => en[key as keyof typeof en])); const rows = table!.querySelectorAll("tbody tr"); expect(rows).toHaveLength(3); - const measured = [...rows[0]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); - expect(measured?.slice(4, 9)).toEqual(["1000", "120", "600", "100", "60%"]); - const partial = [...rows[1]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); - expect(partial?.slice(6, 9)).toEqual(["450", "0", "—"]); - const unavailable = [...rows[2]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); - expect(unavailable?.slice(6, 9)).toEqual(["—", "—", "—"]); + const cells = (row: Element) => [...row.querySelectorAll("td")].map(cell => cell.textContent?.trim()); + // The hit-rate cell carries the rate and, when coverage is partial or absent, the same + // sentence twice over: a `title` for a pointer and an `sr-only` span for everyone else. + const hitRateCell = (row: Element) => row.querySelectorAll("td")[11]!; + const hitRate = (row: Element) => hitRateCell(row).querySelector(".usage-hit-rate")?.textContent?.trim(); + const coverageNote = (row: Element) => hitRateCell(row).querySelector(".sr-only")?.textContent ?? null; + expect(cells(rows[0]!).slice(7, 11)).toEqual(["1000", "120", "600", "100"]); + expect(hitRate(rows[0]!)).toBe("60%"); + // A row whose cache detail covers its whole input needs no coverage caveat. + expect(hitRateCell(rows[0]!).getAttribute("title")).toBeNull(); + expect(coverageNote(rows[0]!)).toBeNull(); + // Half this row's input never reported cache detail. The rate is still an average over the + // half that did, so it is reported with its coverage rather than withheld. + const partialNote = en["usage.cacheHitRate.partial"].replace("{measured}", "500").replace("{total}", "1000"); + expect(cells(rows[1]!).slice(9, 11)).toEqual(["450", "0"]); + expect(hitRate(rows[1]!)).toBe("90%"); + expect(hitRateCell(rows[1]!).getAttribute("title")).toBe(partialNote); + expect(coverageNote(rows[1]!)).toBe(partialNote); + // Nothing in this row reported cache detail at all, which is the one case with no basis. + expect(cells(rows[2]!).slice(9, 11)).toEqual(["—", "—"]); + expect(hitRate(rows[2]!)).toBe("—"); + expect(hitRateCell(rows[2]!).getAttribute("title")).toBe(en["usage.cacheHitRate.unmeasured"]); + expect(coverageNote(rows[2]!)).toBe(en["usage.cacheHitRate.unmeasured"]); }); async function respond(index: number, marker: string, date?: string) { diff --git a/gui/tests/usage-layout.test.ts b/gui/tests/usage-layout.test.ts index c7d4cecd604..fb9a57356ad 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -34,6 +34,30 @@ test("Usage renders every section in one scrollable column with a sticky strip", expect(css).toContain("position: sticky"); }); +test("the usage models table scrolls sideways with model and provider pinned", async () => { + const page = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); + const css = await Bun.file(new URL("../src/styles-usage-workspace.css", import.meta.url)).text(); + + // The table opts into the rules below by class. Without it `.tbl`'s `width: 100%` divides the + // shell across every column until an eight-digit token total folds onto a second line. + expect(page).toContain('className="tbl usage-models-tbl"'); + // Doubled selector on purpose: this file is `@import`ed from the top of `styles.css`, so a + // single class ties `.tbl { width: 100% }` and loses to it on source order. + expect(css).toMatch(/\.tbl\.usage-models-tbl \{[^}]*width: max-content/); + // Model and provider stay readable while the numbers scroll under them. The offsets are one + // scrollport padding step negative so a stuck cell repaints the strip it slides over. + expect(css).toMatch(/\.tbl\.usage-models-tbl td:nth-child\(2\) \{\s*position: sticky/); + expect(css).not.toMatch(/^\.usage-models-tbl/m); + expect(css).toContain("left: calc(-1 * var(--space-3));"); + expect(css).toContain("left: calc(var(--usage-models-model-col) - var(--space-3));"); + // `--hover` is a 3% overlay, so a pinned cell that takes it as its whole background turns + // nearly transparent and the scrolled columns read through it. + expect(css).toContain("background: linear-gradient(var(--hover), var(--hover)), var(--surface);"); + // The excluded-request caption is a line under the amount, not a wrap of the same line. + expect(page).toContain("usage-cost-note"); + expect(css).toMatch(/\.usage-cost-note \{[^}]*display: block/); +}); + test("connected Usage defaults to the exact machine key and can toggle hub-wide without local fallback", async () => { const src = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); expect(src).toContain('useState("machine")'); From 65584900e6df0d357cf53e26a514cd332f63c185 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:06:30 +0900 Subject: [PATCH 37/87] fix(codex): complete the provider-table transition on a paginated openai home (#5331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): complete the provider-table transition on a paginated openai home A provider-table transition that found an `openai`-tagged thread row Codex had already migrated to paginated history was refused outright. Nothing was written — no config, no reference profile, no `model_catalog_json` — and the integration stayed disabled, with a message that said only "do not retry". The reporter's home had 173 such conversations, and the only exits they could find by reading the preflight source were deleting them or downgrading (#5321). The danger the guard names is real: the transition removes the root `openai_base_url`, and a paginated row cannot be relabeled, so that conversation would resume against Codex's built-in OpenAI endpoint instead of this proxy. The injector already builds the state that resolves it. `keepRootOverrideAlongsideTable` keeps the marker-owned root override beside the provider table for the client-compaction form, for exactly this reason. This selects the same retention from the preflight verdict rather than from the routing form: when the reason is the paginated-openai code and the target can own a root key, the override is retained, the reason is downgraded to the stand-down constant so the relabel unit never starts, and the transition completes. The paginated row is neither read nor written, and it keeps resolving to this proxy. The retained value is journaled as OpenCodex's own, so restore can still take it out. An admission-token form still refuses, because Codex's built-in `openai` entry cannot carry `x-opencodex-api-key`; its message now names `unauthenticatedLoopbackListener` and `syncResumeHistory` rather than telling the operator not to retry. A root line the user owns is left alone, and the conversation follows the destination they chose. The logic lives in a new module because `src/codex/inject.ts` had three lines of file-size-ratchet headroom. The refusal code is now an exported constant, since the same literal in two files is how the stand-down pair drifted the first time. * fix(cli): surface --remove-codex-provider-table in help and the registry The flag has worked since the #4812 teardown fix, but it appeared in no usage string and no help text, so a user whose paginated restore kept `[model_providers.opencodex]` could only find it by reading `dispatch.ts`. It is now in the command registry entries for `restore` and `eject` and in top-level usage. * test(codex): pin the paginated openai transition and its recovery The end-to-end case previously asserted the refusal, so it is rewritten around the completed transition: the config carries both `[model_providers.opencodex]` and the marker-owned root override, the rollout bytes and the thread row are unchanged, and a following `ocx restore` takes the retained override back out. That last assertion is what keeps this from trading #5321 for a new #4812. The resolver gets its own file rather than growing the 1938-line integration suite: placement of the retained line before the first table, CRLF preservation, a user-owned root line left untouched and not claimed in the journal, the admission-token refusal naming two remedies that are asserted to exist as keys in `src/types/config.ts`, every other reason passing through with the text it always had, and a source-oracle check that the refusal code is defined once and read from that definition. The CLI case reads the removal flag out of `dispatch.ts` instead of restating it, so a rename cannot leave the help text silently wrong. * docs: correct the paginated-history recovery story and document the retention Two things were stale or missing in the Codex integration guide across all eight locales. It still said `ocx restore` and Codex config removal refuse on `history_paginated_requires_native_writer` and that a paginated home cannot be uninstalled through the product; both stopped being true when #4812 landed the degraded restore. And nothing described what a provider-table transition now does when it meets an already-paginated `openai` conversation. `structure/config.md` and `structure/codex-home.md` gain the apply-side rule: the paginated-openai reason selects the root-override retention in the same window as the existing provider-table retention, before the witness, and only an admission-token form still refuses. * docs(devlog): record the R1 paginated-history guard lane What the guard protected, why refusing the whole transition was the lockout rather than the protection, why the fix selects an existing mechanism instead of adding one, the evidence that #4812's recovery half is already closed on dev, and the recovery reasons deliberately left open. * test(codex): use the real routing marker in the paginated openai fixture The fixture this case inherited wrote `# >>> opencodex managed openai_base_url >>>`, a marker string that appears nowhere in `src/`. It never mattered while the case asserted that nothing was written. Now that the transition completes, the injector correctly read that unrecognised line as user-owned, left it alone, and the case failed against its own fiction. The fixture is now the reporter's actual shape: a loopback root-override home, marked with `OCX_ROUTING_MARKER_LINE`, turning on `codexDesktopAuthless`. The injector strips its own line, builds the table, and the retention writes a marker-owned override back. The user-owned variant is covered separately in the resolver's own file. --- .../010_r1_paginated_history_guard.md | 91 ++++++++++++++ .../docs/fr/guides/codex-integration.md | 2 +- .../content/docs/guides/codex-integration.md | 4 +- .../docs/ja/guides/codex-integration.md | 2 +- .../docs/ko/guides/codex-integration.md | 2 +- .../docs/ru/guides/codex-integration.md | 2 +- .../docs/tr/guides/codex-integration.md | 2 +- .../docs/zh-cn/guides/codex-integration.md | 2 +- .../docs/zh-tw/guides/codex-integration.md | 2 +- scripts/test-layout/layout.json | 1 + src/cli/help.ts | 2 + src/cli/registry.ts | 6 + src/codex/history-provider.ts | 13 +- src/codex/inject.ts | 33 ++--- src/codex/inject/paginated-openai-compat.ts | 90 +++++++++++++ structure/codex-home.md | 2 + structure/config.md | 21 +++- tests/cli/cli-restore-back.test.ts | 8 ++ .../codex-inject-integration.test.ts | 34 ++++- .../history-paginated-openai-compat.test.ts | 119 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 21 files changed, 406 insertions(+), 33 deletions(-) create mode 100644 devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md create mode 100644 src/codex/inject/paginated-openai-compat.ts create mode 100644 tests/codex-integration/history-paginated-openai-compat.test.ts diff --git a/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md b/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md new file mode 100644 index 00000000000..a3db4957992 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md @@ -0,0 +1,91 @@ +# R1 — the paginated-history guard, from activation and from recovery + +Scope: #5321 (activation) and #4812 (recovery). Branch `codex/260920-r1-paginated-history-guard`. + +## What the guard was actually protecting + +`preflightCodexHistoryInjection` returns `history_paginated_openai_requires_native_writer` when a +provider-table transition finds a thread row that is both `model_provider = 'openai'` and +`history_mode = 'paginated'`. The reasoning is sound. The transition takes the root +`openai_base_url` out, a paginated row cannot be relabeled, and Codex builds its provider map as +`merge_configured_model_providers(built_in_model_providers(openai_base_url), model_providers)`, so +without that root line the built-in `openai` entry is `api.openai.com`. The conversation would +resume outside the proxy. + +What made it a lockout is that 2.60.0 classified it alongside "something is wrong with this +store". `src/codex/inject.ts` refuses every reason that is not exactly `HISTORY_RELABEL_STANDS_DOWN`, +so nothing was written at all: no config, no profile, no `model_catalog_json`, integration +disabled. Before 2.60.0 the same home returned the plain stand-down, and the routing and catalog +half landed while the relabel stood down. + +## The state that was already in the tree + +The injector already builds the safe state for one routing form. `keepRootOverrideAlongsideTable` +keeps the marker-owned root override beside the provider table for client compaction, for exactly +this reason, and passes `resumeHistory: false` so the relabel never runs. Authless was excluded +deliberately — its point is `requires_openai_auth = false` — on the assumption that it could +always forward-tag resume history instead. On a paginated home that assumption is false, and the +refusal is where that showed up. + +So the fix is not a new mechanism. `src/codex/inject/paginated-openai-compat.ts` selects the +existing one from the preflight verdict rather than from the routing form: when the reason is the +paginated-openai code and the target can own a root key, retain the override, downgrade the reason +to the stand-down constant, and let the transition complete. The paginated row is never read or +written; it simply keeps resolving to this proxy. + +Two cases cannot reach that state, and both are honest outcomes rather than traps: + +- An admission-token form cannot use the root key at all, because Codex's built-in `openai` entry + carries no `x-opencodex-api-key` header. It keeps the refusal, and the message now names + `unauthenticatedLoopbackListener` and `syncResumeHistory` instead of "do not retry". +- A root line the user owns is left alone. The conversation follows the destination they chose, + which is the guarantee the injector already makes everywhere else about a line it does not own, + and the journal correctly records the line as not ours. + +## Where it had to live + +`src/codex/inject.ts` was at 984 of its 987-line ratchet cap, so the decision could not be +inlined. The new module costs the injector one import and one net line; the file now sits at +exactly 987. The refusal code became an exported constant in `src/codex/history-provider.ts` +because the same literal in two files is how the stand-down pair drifted the first time. + +## #4812, checked rather than assumed + +The recovery half is already closed on `dev`: `resolveRestoreHistoryDisposition` stands down on +`HISTORY_RELABEL_STANDS_DOWN` and removal retains the provider table. The new code cannot reach +restore at all — it is only set under `providerTableMode`, and restore preflights with +`providerTableMode = false`, whose row predicate is `model_provider = 'opencodex'`. + +Two things were still wrong on that side. `ocx restore --remove-codex-provider-table` existed but +appeared in no usage or help text, so the escape hatch was reachable only by reading the parser; +it is now in the command registry and top-level usage, bound by a test that reads the flag out of +`dispatch.ts` rather than restating it. And the public guide in all eight locales still said +restore and removal refuse on paginated history and that such a home cannot be uninstalled, which +has not been true since 2026-09-17. + +## Verification + +Static review plus exact-head hosted CI. Per the lane constraints, NOT RUN locally: `bun test`, +any individual test file, `bun run typecheck`, any build, any install, live `ocx`, service +restart, and credential or configuration changes. + +Regression coverage added: + +- `tests/codex-integration/history-paginated-openai-compat.test.ts` — the resolver itself: root + override retained and placed before the first table, CRLF preserved, a user-owned line left + untouched and not claimed, the admission-token refusal naming both remedies as keys that are + asserted to exist in `src/types/config.ts`, every other reason passing through unchanged, and a + source-oracle check that the refusal code is defined once. +- `tests/codex-integration/codex-inject-integration.test.ts` — the end-to-end regression, rewritten + from "refuses" to the full transition: config carries both the table and the marker-owned root + override, the rollout bytes and the thread row are unchanged, and `ocx restore` afterwards takes + the retained override back out. That last assertion is the one that keeps this from trading + #5321 for a new #4812. +- `tests/cli/cli-restore-back.test.ts` — the removal flag is discoverable in both help surfaces. + +## Not in this lane + +The other hard-refusal reasons on the recovery side still have no named repair command: a missing +state database with pending manifest entries, and a backup manifest that is unreadable, foreign, +or schema-invalid. Those are a different failure family from the guard and are left open rather +than folded in here. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index b4467db2701..f5aada09618 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -419,7 +419,7 @@ Codex. Seule l'exécution explicite de `ocx stop` ou `ocx service stop` restaure ## Refus de sécurité pour l’historique paginé -Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. Cette raison ne refuse plus la configuration Codex, le profil de référence ni le catalogue de modèles. `ocx sync` et `ocx start` écrivent toujours ces fichiers et définissent `model_catalog_json`, afin que le sélecteur de modèles Codex continue d’afficher tous les modèles routés par OpenCodex. Seule cette raison interrompt le réétiquetage de l’historique des conversations, car Codex attribue les numéros d’historique paginé dans son propre processus d’écriture et aucune nouvelle tentative n’y change rien. Toute autre raison de contrôle préalable de l’historique — une base d’état illisible, un historique dont l’identité a changé, ou un contrôle préalable qui n’a pas pu s’exécuter — refuse encore toute la transition et l’annule, car ces cas peuvent réussir plus tard. Dans cet état, OpenCodex ne modifie jamais les fichiers d’historique paginé ni les lignes de conversation. Les conversations existantes conservent le fournisseur déjà associé et ne sont pas migrées ; les nouvelles conversations passent par le proxy. Lorsque le réétiquetage est interrompu, une table `[model_providers.opencodex]` déjà présente dans le répertoire d’accueil est conservée plutôt que retirée, y compris sous la forme root-override (loopback), afin que les conversations dont les lignes sont étiquetées `opencodex` gardent un identifiant de fournisseur qui existe encore. Le CLI affiche `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` et la suppression de la configuration Codex refusent toujours sur `history_paginated_requires_native_writer`. Retirer la définition `[model_providers.opencodex]` alors que des lignes de conversation la référencent encore rendrait ces conversations irrésolubles, et le chemin de restauration n’a aucun moyen de conserver une table de fournisseur de compatibilité. Un répertoire d’accueil déjà paginé ne peut pas actuellement être désinstallé par le produit ; c’est un travail ouvert connu, et non le comportement voulu. +Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. Cette raison ne refuse plus la configuration Codex, le profil de référence ni le catalogue de modèles. `ocx sync` et `ocx start` écrivent toujours ces fichiers et définissent `model_catalog_json`, afin que le sélecteur de modèles Codex continue d’afficher tous les modèles routés par OpenCodex. Seule cette raison interrompt le réétiquetage de l’historique des conversations, car Codex attribue les numéros d’historique paginé dans son propre processus d’écriture et aucune nouvelle tentative n’y change rien. Toute autre raison de contrôle préalable de l’historique — une base d’état illisible, un historique dont l’identité a changé, ou un contrôle préalable qui n’a pas pu s’exécuter — refuse encore toute la transition et l’annule, car ces cas peuvent réussir plus tard. Dans cet état, OpenCodex ne modifie jamais les fichiers d’historique paginé ni les lignes de conversation. Les conversations existantes conservent le fournisseur déjà associé et ne sont pas migrées ; les nouvelles conversations passent par le proxy. Lorsque le réétiquetage est interrompu, une table `[model_providers.opencodex]` déjà présente dans le répertoire d’accueil est conservée plutôt que retirée, y compris sous la forme root-override (loopback), afin que les conversations dont les lignes sont étiquetées `opencodex` gardent un identifiant de fournisseur qui existe encore. Le CLI affiche `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` et `ocx uninstall` ne refusent plus sur `history_paginated_requires_native_writer`. Ils retirent toutes les clés de routage racine d'OpenCodex et conservent la définition `[model_providers.opencodex]` sur le disque : les conversations dont les lignes nomment encore ce fournisseur restent résolubles, tandis que `codex` seul cesse de pointer vers le proxy. Le résultat est signalé comme une restauration partielle qui nomme les lignes conservées, et `ocx restore --remove-codex-provider-table` les supprime aussi, après quoi ces conversations ne s'ouvrent plus. Par ailleurs, activer l'intégration sous sa forme table de fournisseur sur un répertoire d'accueil dont les conversations marquées `openai` ont déjà été paginées par Codex était auparavant refusé d'emblée avec `history_paginated_openai_requires_native_writer` : rien n'était écrit et l'intégration restait désactivée. OpenCodex termine désormais cette transition en conservant la redéfinition racine gérée `openai_base_url` à côté de la table `[model_providers.opencodex]`. Codex fusionne cette redéfinition avec son fournisseur `openai` intégré, donc ces conversations continuent d'atteindre le proxy sans être réétiquetées, et aucun octet d'historique ni ligne de conversation n'est modifié. Seule une forme de routage exigeant l'en-tête d'admission `x-opencodex-api-key` refuse encore, car le fournisseur intégré de Codex ne peut pas porter cet en-tête ; son message nomme les deux réglages qui résolvent la situation — router Codex par l'écouteur loopback pour conserver la redéfinition, ou mettre `syncResumeHistory` à `false` en acceptant que ces conversations reprennent sur le point de terminaison OpenAI propre à Codex. Lors du retour au mode de remplacement de l’URL racine, OpenCodex conserve la définition `[model_providers.opencodex]` existante avant de valider la configuration, même si la vérification préalable de l’historique réussit. Les anciennes conversations `opencodex` peuvent ainsi toujours retrouver leur fournisseur si Codex migre l’historique après cette validation ou pendant le démarrage du traitement en arrière-plan. Les nouvelles conversations utilisent le fournisseur racine sélectionné ; la restauration explicite conserve ses contrôles de suppression distincts. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 3afb7af916f..6513400b4a6 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -919,7 +919,9 @@ When an affected history store supports paginated records, a provider transition When returning to the root-override form, OpenCodex retains an existing `[model_providers.opencodex]` definition before committing the configuration, even if history preflight currently passes. This keeps older `opencodex` conversations resolvable if Codex migrates history after that commit or while the background worker starts. New conversations still use the selected root provider; explicit restore keeps its separate removal guards. -`ocx restore` and Codex config removal still refuse on `history_paginated_requires_native_writer`. Stripping the `[model_providers.opencodex]` definition while thread rows still reference it would make those conversations unresolvable, and the restore path has no way to keep a compatibility provider table. A home that is already paginated cannot currently be uninstalled through the product; that is known open work rather than intended behaviour. +`ocx restore`, `ocx stop` and `ocx uninstall` no longer refuse on `history_paginated_requires_native_writer`. They take every OpenCodex root routing key out and keep the `[model_providers.opencodex]` definition on disk, so conversations whose rows still name that provider keep resolving while plain `codex` stops pointing at the proxy. The result is reported as a partial restore that names the retained lines, and `ocx restore --remove-codex-provider-table` removes them too, after which those conversations stop opening. + +Enabling the integration in its provider-table form on a home whose `openai`-tagged conversations Codex has already paginated used to be refused outright with `history_paginated_openai_requires_native_writer`: nothing was written and the integration stayed disabled. OpenCodex now completes that transition by keeping the managed root `openai_base_url` override beside the `[model_providers.opencodex]` table. Codex merges the override onto its built-in `openai` provider, so those conversations keep reaching the proxy without being relabeled and no rollout byte or thread row is touched. Only a routing form that requires the `x-opencodex-api-key` admission header still refuses, because Codex's built-in provider cannot carry that header; its message names the two settings that resolve it — route Codex through the loopback listener so the override can be retained, or set `syncResumeHistory` to `false` to accept that those conversations resume against Codex's own OpenAI endpoint. Do not rewrite an active paginated rollout or thread row to migrate those conversations yourself. Close the affected conversation before any recovery, and report the exact error and versions without uploading private history. A backup or a successful script alone does not prove the conversation is visible again. Check the restored conversation in Codex after reopening. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 31134ea6a38..4bd4530decc 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -281,7 +281,7 @@ opencodex が管理対象 [バックグラウンドサービス](/reference/cli/ ## ページ分割履歴の保護による拒否 -対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` を返すことがあります。この理由では、Codex の設定、参照プロファイル、モデルカタログは拒否されません。`ocx sync` と `ocx start` はこれらのファイルを書き込み、`model_catalog_json` を設定するため、Codex のモデル選択には OpenCodex 経由のモデルがすべて表示され続けます。会話履歴の再ラベル付けを控えるのはこの理由だけの場合です。ページ分割された履歴の番号は Codex 自身の書き込み処理が割り当て、再試行しても変わりません。読み取れない状態データベース、識別子が変わった履歴、実行できなかった事前検査など、それ以外の履歴事前検査の理由では、後から成功する可能性があるため、遷移全体を拒否してロールバックします。この状態では OpenCodex はページ分割された履歴ファイルやスレッド行を変更しません。既存の会話はすでに付いているプロバイダーのまま移行されず、新しい会話は通常どおりプロキシ経由でルーティングされます。再ラベル付けを控えるとき、ホームに既にある `[model_providers.opencodex]` テーブルは廃止せず残します。ルート上書き(loopback)形式でも同じで、行が `opencodex` と付いている会話は、まだ存在するプロバイダー id を保てます。移行可能なストアの legacy 行も対象です。CLI は `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)` と表示します。`ocx restore` と Codex 設定の削除は、いまも `history_paginated_requires_native_writer` で拒否されます。スレッド行がまだ参照しているのに `[model_providers.opencodex]` 定義を外すと、それらの会話は解決できなくなり、復元経路には互換プロバイダー表を残す手段がありません。すでにページ分割されているホームは、現状では製品からアンインストールできません。意図した動作ではなく、既知の未解決作業です。 +対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` を返すことがあります。この理由では、Codex の設定、参照プロファイル、モデルカタログは拒否されません。`ocx sync` と `ocx start` はこれらのファイルを書き込み、`model_catalog_json` を設定するため、Codex のモデル選択には OpenCodex 経由のモデルがすべて表示され続けます。会話履歴の再ラベル付けを控えるのはこの理由だけの場合です。ページ分割された履歴の番号は Codex 自身の書き込み処理が割り当て、再試行しても変わりません。読み取れない状態データベース、識別子が変わった履歴、実行できなかった事前検査など、それ以外の履歴事前検査の理由では、後から成功する可能性があるため、遷移全体を拒否してロールバックします。この状態では OpenCodex はページ分割された履歴ファイルやスレッド行を変更しません。既存の会話はすでに付いているプロバイダーのまま移行されず、新しい会話は通常どおりプロキシ経由でルーティングされます。再ラベル付けを控えるとき、ホームに既にある `[model_providers.opencodex]` テーブルは廃止せず残します。ルート上書き(loopback)形式でも同じで、行が `opencodex` と付いている会話は、まだ存在するプロバイダー id を保てます。移行可能なストアの legacy 行も対象です。CLI は `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)` と表示します。`ocx restore`、`ocx stop`、`ocx uninstall` は `history_paginated_requires_native_writer` で拒否しなくなりました。OpenCodex が書いたルートのルーティングキーをすべて取り除き、`[model_providers.opencodex]` の定義はディスクに残します。そのプロバイダーを指している会話は解決でき、素の `codex` はプロキシを向かなくなります。結果は残した行を示す部分復元として報告され、`ocx restore --remove-codex-provider-table` を使えばその行も削除できます。そのときは該当の会話が開かなくなります。また、`openai` と付いた会話を Codex がすでにページ分割したホームでプロバイダーテーブル形式の統合を有効にすると、以前は `history_paginated_openai_requires_native_writer` で全体が拒否され、何も書かれず統合も無効のままでした。現在は、管理対象のルート `openai_base_url` 上書きを `[model_providers.opencodex]` テーブルと一緒に残す形で移行を完了します。Codex はこの上書きを組み込みの `openai` プロバイダーに統合するため、それらの会話は再ラベル付けなしでプロキシに届き、履歴ファイルやスレッド行は変更されません。`x-opencodex-api-key` の受け入れヘッダーを必要とするルーティング形式だけは今も拒否されます。組み込みプロバイダーがそのヘッダーを運べないためで、そのメッセージは解決策を二つ名指しします。ループバックリスナー経由で Codex を接続して上書きを維持するか、`syncResumeHistory` を `false` にして、それらの会話が Codex 自身の OpenAI エンドポイントに向かうことを受け入れるかです。 ルート URL 上書き方式に戻すとき、履歴の事前確認が成功していても、OpenCodex は設定を確定する前に既存の `[model_providers.opencodex]` 定義を保持します。確定後やバックグラウンドの履歴処理開始中に Codex が履歴形式を移行しても、以前の `opencodex` 会話はプロバイダーを引き続き解決できます。新しい会話は選択されたルートプロバイダーを使い、明示的な復元には従来の個別の削除チェックが適用されます。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 72fef21fea1..1aefaeb8b75 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -391,7 +391,7 @@ opencodex가 managed [background service](/reference/cli/#ocx-service)로 실행 ## 페이지 분할 기록 보호에 따른 거부 -영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`를 반환할 수 있습니다. 이 이유로는 Codex 설정, 참조 프로필, 모델 카탈로그를 더 이상 거부하지 않습니다. `ocx sync`와 `ocx start`는 해당 파일과 `model_catalog_json`을 계속 쓰므로 Codex 모델 선택기에는 OpenCodex가 라우팅하는 모델이 모두 그대로 보입니다. 대화 기록의 프로바이더 재지정을 건너뛰는 것은 이 이유뿐이며, 페이지 분할 순번은 Codex 자체의 네이티브 기록 작성자가 할당하고 재시도해도 달라지지 않기 때문입니다. 읽을 수 없는 상태 데이터베이스, 식별자가 바뀐 대화 원본, 실행하지 못한 사전 검사처럼 다른 기록 사전 검사 이유는 나중에 성공할 수 있으므로 전환 전체를 거부하고 되돌립니다. 이 상태에서 OpenCodex는 페이지 분할 대화 원본이나 스레드 행을 수정하지 않습니다. 기존 대화는 이미 붙어 있는 프로바이더를 유지하고 이전되지 않으며, 새 대화는 평소처럼 프록시를 통해 라우팅됩니다. 재지정을 건너뛸 때 홈에 이미 있던 `[model_providers.opencodex]` 테이블은 폐기하지 않고 유지합니다. root-override(loopback) 형식에서도 같아서, 행이 `opencodex`로 표시된 대화는 아직 존재하는 프로바이더 id를 유지합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. CLI는 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`를 출력합니다. `ocx restore`와 Codex 설정 제거는 여전히 `history_paginated_requires_native_writer`로 거부됩니다. 스레드 행이 아직 참조하는데 `[model_providers.opencodex]` 정의를 걷어내면 그 대화를 해석할 수 없고, 복원 경로에는 호환 프로바이더 테이블을 남겨 둘 방법이 없습니다. 이미 페이지 분할된 홈은 지금은 제품으로 제거할 수 없습니다. 의도한 동작이 아니라 알려진 미해결 작업입니다. +영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`를 반환할 수 있습니다. 이 이유로는 Codex 설정, 참조 프로필, 모델 카탈로그를 더 이상 거부하지 않습니다. `ocx sync`와 `ocx start`는 해당 파일과 `model_catalog_json`을 계속 쓰므로 Codex 모델 선택기에는 OpenCodex가 라우팅하는 모델이 모두 그대로 보입니다. 대화 기록의 프로바이더 재지정을 건너뛰는 것은 이 이유뿐이며, 페이지 분할 순번은 Codex 자체의 네이티브 기록 작성자가 할당하고 재시도해도 달라지지 않기 때문입니다. 읽을 수 없는 상태 데이터베이스, 식별자가 바뀐 대화 원본, 실행하지 못한 사전 검사처럼 다른 기록 사전 검사 이유는 나중에 성공할 수 있으므로 전환 전체를 거부하고 되돌립니다. 이 상태에서 OpenCodex는 페이지 분할 대화 원본이나 스레드 행을 수정하지 않습니다. 기존 대화는 이미 붙어 있는 프로바이더를 유지하고 이전되지 않으며, 새 대화는 평소처럼 프록시를 통해 라우팅됩니다. 재지정을 건너뛸 때 홈에 이미 있던 `[model_providers.opencodex]` 테이블은 폐기하지 않고 유지합니다. root-override(loopback) 형식에서도 같아서, 행이 `opencodex`로 표시된 대화는 아직 존재하는 프로바이더 id를 유지합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. CLI는 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`를 출력합니다. `ocx restore`, `ocx stop`, `ocx uninstall`은 이제 `history_paginated_requires_native_writer`로 거부하지 않습니다. OpenCodex가 넣은 루트 라우팅 키를 모두 걷어내고 `[model_providers.opencodex]` 정의는 디스크에 남기므로, 그 프로바이더를 가리키는 대화는 계속 열리고 plain `codex`는 더 이상 프록시를 향하지 않습니다. 결과는 남겨 둔 줄을 함께 알려 주는 부분 복원으로 보고되며, `ocx restore --remove-codex-provider-table`을 쓰면 그 줄까지 지웁니다. 대신 해당 대화는 열리지 않게 됩니다. 한편 `openai`로 표시된 대화를 Codex가 이미 페이지 분할한 홈에서 프로바이더 테이블 형식으로 통합을 켜면, 예전에는 `history_paginated_openai_requires_native_writer`로 전체가 거부되어 아무것도 쓰이지 않고 통합도 꺼진 채로 남았습니다. 지금은 관리 대상 루트 `openai_base_url` 재정의를 `[model_providers.opencodex]` 테이블과 함께 남겨 두는 방식으로 전환을 끝냅니다. Codex가 이 재정의를 내장 `openai` 프로바이더에 합치므로 해당 대화는 재지정 없이 계속 프록시에 닿고, 대화 원본이나 스레드 행은 건드리지 않습니다. `x-opencodex-api-key` 승인 헤더가 필요한 라우팅 형식만 여전히 거부합니다. 내장 프로바이더가 그 헤더를 실을 수 없기 때문이며, 이때 메시지는 해결 방법 두 가지를 이름으로 알려 줍니다. 루프백 리스너로 Codex를 연결해 재정의를 유지하거나, `syncResumeHistory`를 `false`로 두어 해당 대화가 Codex 자체 OpenAI 엔드포인트로 이어지는 것을 받아들이는 것입니다. 루트 URL 재정의 방식으로 돌아갈 때 OpenCodex는 기록 사전 점검이 통과하더라도 기존 `[model_providers.opencodex]` 정의를 설정 적용 전에 유지합니다. 설정 적용 후나 백그라운드 기록 작업 시작 중에 Codex가 기록 형식을 전환해도 이전 `opencodex` 대화가 제공자를 계속 찾을 수 있습니다. 새 대화는 선택된 루트 제공자를 사용하며, 명시적 복원에는 기존의 별도 제거 검사가 적용됩니다. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 5e1a62ec71b..056f4b42917 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -413,7 +413,7 @@ ocx restore back # point plain Codex at the running proxy again ## Защитный отказ для постраничной истории -Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. По этой причине больше не отклоняются конфигурация Codex, опорный профиль и каталог моделей. `ocx sync` и `ocx start` по-прежнему записывают эти файлы и задают `model_catalog_json`, поэтому выбор модели Codex продолжает показывать все модели, маршрутизируемые через OpenCodex. Переразметку истории разговоров останавливает только эта причина: порядковые номера постраничной истории выделяет собственный процесс записи Codex, и повторная попытка этого не меняет. Любая другая причина предварительной проверки истории — нечитаемая база состояния, история со сменившейся идентификацией или проверка, которую не удалось запустить, — по-прежнему отклоняет весь переход и откатывает его, потому что такие случаи могут пройти позже. В этом состоянии OpenCodex не изменяет постраничные файлы истории и строки тредов. Существующие разговоры сохраняют уже назначенного провайдера и не мигрируют; новые разговоры идут через прокси как обычно. Когда переразметка останавливается, таблица `[model_providers.opencodex]`, уже бывшая в домашнем каталоге, сохраняется, а не снимается, в том числе в форме root-override (loopback), чтобы разговоры со строками, помеченными `opencodex`, сохраняли существующий идентификатор провайдера. CLI выводит `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` и удаление конфигурации Codex по-прежнему отказывают по `history_paginated_requires_native_writer`. Удаление определения `[model_providers.opencodex]`, пока строки тредов на него ссылаются, сделало бы эти разговоры неразрешимыми, а путь восстановления не умеет оставлять таблицу совместимости провайдера. Домашний каталог, уже переведённый на постраничную историю, сейчас нельзя удалить средствами продукта; это известная открытая задача, а не задуманное поведение. +Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. По этой причине больше не отклоняются конфигурация Codex, опорный профиль и каталог моделей. `ocx sync` и `ocx start` по-прежнему записывают эти файлы и задают `model_catalog_json`, поэтому выбор модели Codex продолжает показывать все модели, маршрутизируемые через OpenCodex. Переразметку истории разговоров останавливает только эта причина: порядковые номера постраничной истории выделяет собственный процесс записи Codex, и повторная попытка этого не меняет. Любая другая причина предварительной проверки истории — нечитаемая база состояния, история со сменившейся идентификацией или проверка, которую не удалось запустить, — по-прежнему отклоняет весь переход и откатывает его, потому что такие случаи могут пройти позже. В этом состоянии OpenCodex не изменяет постраничные файлы истории и строки тредов. Существующие разговоры сохраняют уже назначенного провайдера и не мигрируют; новые разговоры идут через прокси как обычно. Когда переразметка останавливается, таблица `[model_providers.opencodex]`, уже бывшая в домашнем каталоге, сохраняется, а не снимается, в том числе в форме root-override (loopback), чтобы разговоры со строками, помеченными `opencodex`, сохраняли существующий идентификатор провайдера. CLI выводит `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` и `ocx uninstall` больше не отказывают по причине `history_paginated_requires_native_writer`. Они убирают все корневые ключи маршрутизации OpenCodex и оставляют определение `[model_providers.opencodex]` на диске, поэтому разговоры, строки которых всё ещё называют этого провайдера, продолжают разрешаться, а обычный `codex` перестаёт указывать на прокси. Результат сообщается как частичное восстановление с перечислением оставленных строк; `ocx restore --remove-codex-provider-table` удаляет и их, после чего такие разговоры перестают открываться. Кроме того, включение интеграции в форме таблицы провайдера в домашнем каталоге, где разговоры с меткой `openai` Codex уже перевёл на постраничную историю, раньше отклонялось целиком с `history_paginated_openai_requires_native_writer`: ничего не записывалось, а интеграция оставалась выключенной. Теперь OpenCodex завершает этот переход, сохраняя управляемое корневое переопределение `openai_base_url` рядом с таблицей `[model_providers.opencodex]`. Codex объединяет это переопределение со своим встроенным провайдером `openai`, поэтому такие разговоры продолжают попадать в прокси без переразметки, а файлы истории и строки тредов не изменяются. Отказ сохраняется только для формы маршрутизации, требующей заголовок допуска `x-opencodex-api-key`, потому что встроенный провайдер Codex не может его нести; в этом случае сообщение называет две настройки, которые решают задачу: направить Codex через loopback-слушатель, чтобы переопределение можно было сохранить, или задать `syncResumeHistory` значение `false`, приняв, что такие разговоры пойдут к собственной конечной точке OpenAI в Codex. При возврате к режиму переопределения корневого URL OpenCodex сохраняет существующее определение `[model_providers.opencodex]` до фиксации конфигурации, даже если предварительная проверка истории успешна. Поэтому старые разговоры `opencodex` сохраняют доступ к своему провайдеру, если Codex преобразует историю после фиксации или во время запуска фоновой обработки. Новые разговоры используют выбранный корневой провайдер; явное восстановление по-прежнему выполняет отдельные проверки удаления. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 4f7b49328ac..a7ccadae455 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -470,7 +470,7 @@ service stop` yerel Codex'i geri yükler. ## Sayfalanmış geçmiş için güvenlik reddi -Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. Bu neden artık Codex yapılandırmasını, başvuru profilini veya model kataloğunu reddetmez. `ocx sync` ve `ocx start` bu dosyaları yazmaya ve `model_catalog_json` yolunu ayarlamaya devam eder; böylece Codex model seçicisi OpenCodex üzerinden yönlendirilen her modeli göstermeyi sürdürür. Konuşma geçmişinin yeniden etiketlenmesini durduran yalnızca bu nedendir, çünkü sayfalanmış geçmiş sıra numaralarını Codex’in kendi yerel yazıcısı atar ve yeniden denemek bunu değiştirmez. Okunamayan bir durum veritabanı, kimliği değişmiş bir geçmiş veya çalıştırılamayan bir ön kontrol gibi diğer geçmiş ön kontrol nedenleri, daha sonra başarılı olabilecekleri için hâlâ tüm değişimi reddeder ve geri alır. Bu durumda OpenCodex sayfalanmış geçmiş dosyalarını veya iş parçacığı satırlarını değiştirmez. Mevcut konuşmalar zaten etiketlendikleri sağlayıcıda kalır ve taşınmaz; yeni konuşmalar proxy üzerinden normal şekilde yönlendirilir. Yeniden etiketleme durduğunda, ev dizininde zaten bulunan bir `[model_providers.opencodex]` tablosu kaldırılmaz, kök-override (loopback) biçimde bile tutulur; böylece satırları `opencodex` olarak etiketlenmiş konuşmalar hâlâ var olan bir sağlayıcı kimliğini korur. CLI şunu yazdırır: `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` ve Codex yapılandırmasının kaldırılması `history_paginated_requires_native_writer` nedeniyle hâlâ reddedilir. İş parçacığı satırları hâlâ ona başvuruyken `[model_providers.opencodex]` tanımını kaldırmak o konuşmaları çözülemez yapar ve geri yükleme yolu uyumluluk sağlayıcı tablosunu tutamaz. Zaten sayfalanmış bir ev dizini şu anda ürün üzerinden kaldırılamaz; bu amaçlanan davranış değil, bilinen açık iştir. +Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. Bu neden artık Codex yapılandırmasını, başvuru profilini veya model kataloğunu reddetmez. `ocx sync` ve `ocx start` bu dosyaları yazmaya ve `model_catalog_json` yolunu ayarlamaya devam eder; böylece Codex model seçicisi OpenCodex üzerinden yönlendirilen her modeli göstermeyi sürdürür. Konuşma geçmişinin yeniden etiketlenmesini durduran yalnızca bu nedendir, çünkü sayfalanmış geçmiş sıra numaralarını Codex’in kendi yerel yazıcısı atar ve yeniden denemek bunu değiştirmez. Okunamayan bir durum veritabanı, kimliği değişmiş bir geçmiş veya çalıştırılamayan bir ön kontrol gibi diğer geçmiş ön kontrol nedenleri, daha sonra başarılı olabilecekleri için hâlâ tüm değişimi reddeder ve geri alır. Bu durumda OpenCodex sayfalanmış geçmiş dosyalarını veya iş parçacığı satırlarını değiştirmez. Mevcut konuşmalar zaten etiketlendikleri sağlayıcıda kalır ve taşınmaz; yeni konuşmalar proxy üzerinden normal şekilde yönlendirilir. Yeniden etiketleme durduğunda, ev dizininde zaten bulunan bir `[model_providers.opencodex]` tablosu kaldırılmaz, kök-override (loopback) biçimde bile tutulur; böylece satırları `opencodex` olarak etiketlenmiş konuşmalar hâlâ var olan bir sağlayıcı kimliğini korur. CLI şunu yazdırır: `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` ve `ocx uninstall` artık `history_paginated_requires_native_writer` nedeniyle reddetmez. OpenCodex'in yazdığı tüm kök yönlendirme anahtarlarını kaldırır ve `[model_providers.opencodex]` tanımını diskte bırakır; böylece satırları hâlâ o sağlayıcıyı adlandıran konuşmalar çözülmeye devam ederken düz `codex` proxy'yi göstermeyi bırakır. Sonuç, bırakılan satırları adlandıran kısmi bir geri yükleme olarak raporlanır; `ocx restore --remove-codex-provider-table` onları da kaldırır ve ardından o konuşmalar açılmaz. Ayrıca, `openai` etiketli konuşmaları Codex'in zaten sayfaladığı bir ev dizininde entegrasyonu sağlayıcı tablosu biçiminde açmak eskiden `history_paginated_openai_requires_native_writer` ile tümüyle reddediliyordu: hiçbir şey yazılmıyor ve entegrasyon devre dışı kalıyordu. OpenCodex bu geçişi artık yönetilen kök `openai_base_url` geçersiz kılmasını `[model_providers.opencodex]` tablosunun yanında tutarak tamamlar. Codex bu geçersiz kılmayı yerleşik `openai` sağlayıcısıyla birleştirdiği için o konuşmalar yeniden etiketlenmeden proxy'ye ulaşmayı sürdürür ve hiçbir geçmiş baytı veya iş parçacığı satırı değişmez. Yalnızca `x-opencodex-api-key` kabul başlığını gerektiren yönlendirme biçimi hâlâ reddeder, çünkü Codex'in yerleşik sağlayıcısı bu başlığı taşıyamaz; mesajı bunu çözen iki ayarı adlandırır: geçersiz kılmanın korunabilmesi için Codex'i loopback dinleyicisi üzerinden yönlendirin ya da `syncResumeHistory` değerini `false` yaparak o konuşmaların Codex'in kendi OpenAI uç noktasına gitmesini kabul edin. Kök URL geçersiz kılma biçimine dönülürken OpenCodex, geçmiş ön kontrolü başarılı olsa bile yapılandırmayı kaydetmeden önce mevcut `[model_providers.opencodex]` tanımını korur. Böylece Codex, kayıttan sonra veya arka plan geçmiş işlemi başlarken geçmiş biçimini değiştirirse eski `opencodex` konuşmaları sağlayıcılarını bulmaya devam eder. Yeni konuşmalar seçili kök sağlayıcıyı kullanır; açıkça istenen geri yükleme, mevcut ayrı kaldırma kontrollerini korur. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 982a734304b..94a00079d97 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -356,7 +356,7 @@ ocx restore back # point plain Codex at the running proxy again ## 分页历史记录安全拒绝 -如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。该原因不再拒绝写入 Codex 配置、参考配置档和模型目录。`ocx sync` 与 `ocx start` 仍会写入这些文件并设置 `model_catalog_json`,因此 Codex 模型选择器会继续显示所有经 OpenCodex 路由的模型。只有这一条原因会让会话历史的重新标记停手,因为分页历史序号由 Codex 自己的写入器分配,重试也不会改变。无法读取的状态数据库、身份已变的历史文件、未能运行的预检等其他历史预检原因仍会拒绝整个切换并回滚,因为那些情况以后可能成功。在此状态下,OpenCodex 不会修改分页历史文件或线程行。现有会话保留已标记的提供商,不会被迁移;新会话仍正常经代理路由。重新标记停手时,主目录里已有的 `[model_providers.opencodex]` 表会保留而不是撤下,即便是 root-override(loopback)形式也一样,这样行上标记为 `opencodex` 的会话仍能对应到还存在的提供商 id。可迁移存储中的 legacy 记录也适用。CLI 会打印 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 和移除 Codex 配置仍会因 `history_paginated_requires_native_writer` 被拒绝。线程行仍在引用时撤掉 `[model_providers.opencodex]` 定义会使这些会话无法解析,而恢复路径没有办法留下兼容提供商表。已经分页的主目录目前无法通过产品卸载;这是已知的未完成工作,而非预期行为。 +如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。该原因不再拒绝写入 Codex 配置、参考配置档和模型目录。`ocx sync` 与 `ocx start` 仍会写入这些文件并设置 `model_catalog_json`,因此 Codex 模型选择器会继续显示所有经 OpenCodex 路由的模型。只有这一条原因会让会话历史的重新标记停手,因为分页历史序号由 Codex 自己的写入器分配,重试也不会改变。无法读取的状态数据库、身份已变的历史文件、未能运行的预检等其他历史预检原因仍会拒绝整个切换并回滚,因为那些情况以后可能成功。在此状态下,OpenCodex 不会修改分页历史文件或线程行。现有会话保留已标记的提供商,不会被迁移;新会话仍正常经代理路由。重新标记停手时,主目录里已有的 `[model_providers.opencodex]` 表会保留而不是撤下,即便是 root-override(loopback)形式也一样,这样行上标记为 `opencodex` 的会话仍能对应到还存在的提供商 id。可迁移存储中的 legacy 记录也适用。CLI 会打印 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore`、`ocx stop` 和 `ocx uninstall` 不再因 `history_paginated_requires_native_writer` 被拒绝。它们会移除 OpenCodex 写入的全部根路由键,并把 `[model_providers.opencodex]` 定义保留在磁盘上,因此行上仍指向该提供商的会话依旧可以解析,而裸 `codex` 不再指向代理。结果会报告为部分恢复并列出保留的行;`ocx restore --remove-codex-provider-table` 会连这些行一并删除,之后那些会话将无法打开。另外,在 Codex 已把 `openai` 标记会话迁移为分页历史的主目录上启用提供商表形式的集成,过去会以 `history_paginated_openai_requires_native_writer` 整体拒绝:什么都不写,集成保持关闭。现在 OpenCodex 会保留受管的根 `openai_base_url` 覆盖,与 `[model_providers.opencodex]` 表并存,从而完成这次切换。Codex 会把该覆盖合并到内置 `openai` 提供商上,所以那些会话无需重新标记即可继续到达代理,历史文件与线程行都不会被改动。只有需要 `x-opencodex-api-key` 准入标头的路由形式仍会拒绝,因为 Codex 内置提供商无法携带该标头;此时消息会点名两个可行设置——让 Codex 走回环监听器以便保留该覆盖,或把 `syncResumeHistory` 设为 `false`,接受那些会话转向 Codex 自己的 OpenAI 端点。 返回根 URL 覆盖模式时,即使历史预检通过,OpenCodex 也会在提交配置前保留已有的 `[model_providers.opencodex]` 定义。这样,即使 Codex 在提交后或后台历史任务启动时迁移历史格式,旧的 `opencodex` 对话仍能找到其提供商。新对话继续使用所选的根提供商;显式恢复仍执行原有的独立删除检查。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index c0ca280658c..15b740e5743 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -363,7 +363,7 @@ ocx restore back # 讓普通 Codex 再次指向仍在執行的 proxy ## 分頁歷史記錄安全拒絕 -如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。此原因不再拒絕寫入 Codex 設定、參考設定檔與模型目錄。`ocx sync` 與 `ocx start` 仍會寫入這些檔案並設定 `model_catalog_json`,因此 Codex 模型選擇器會繼續顯示所有經 OpenCodex 路由的模型。只有這一條原因會讓對話歷史的重新標記停手,因為分頁歷史序號由 Codex 自己的寫入器分配,重試也不會改變。無法讀取的狀態資料庫、身分已變的歷史檔案、未能執行的預檢等其他歷史預檢原因仍會拒絕整個切換並回復,因為那些情況以後可能成功。在此狀態下,OpenCodex 不會修改分頁歷史檔案或執行緒列。既有對話保留已標記的提供者,不會被遷移;新對話仍正常經代理路由。重新標記停手時,家目錄裡既有的 `[model_providers.opencodex]` 表會保留而不是撤下,即便是 root-override(loopback)形式也一樣,這樣列上標記為 `opencodex` 的對話仍能對應到還存在的提供者 id。可遷移儲存區中的 legacy 記錄也適用。CLI 會印出 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 與移除 Codex 設定仍會因 `history_paginated_requires_native_writer` 被拒絕。執行緒列仍在參照時撤掉 `[model_providers.opencodex]` 定義會使這些對話無法解析,而復原路徑沒有辦法留下相容提供者表。已經分頁的家目錄目前無法透過產品解除安裝;這是已知的未完成工作,而非預期行為。 +如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。此原因不再拒絕寫入 Codex 設定、參考設定檔與模型目錄。`ocx sync` 與 `ocx start` 仍會寫入這些檔案並設定 `model_catalog_json`,因此 Codex 模型選擇器會繼續顯示所有經 OpenCodex 路由的模型。只有這一條原因會讓對話歷史的重新標記停手,因為分頁歷史序號由 Codex 自己的寫入器分配,重試也不會改變。無法讀取的狀態資料庫、身分已變的歷史檔案、未能執行的預檢等其他歷史預檢原因仍會拒絕整個切換並回復,因為那些情況以後可能成功。在此狀態下,OpenCodex 不會修改分頁歷史檔案或執行緒列。既有對話保留已標記的提供者,不會被遷移;新對話仍正常經代理路由。重新標記停手時,家目錄裡既有的 `[model_providers.opencodex]` 表會保留而不是撤下,即便是 root-override(loopback)形式也一樣,這樣列上標記為 `opencodex` 的對話仍能對應到還存在的提供者 id。可遷移儲存區中的 legacy 記錄也適用。CLI 會印出 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore`、`ocx stop` 與 `ocx uninstall` 不再因 `history_paginated_requires_native_writer` 被拒絕。它們會移除 OpenCodex 寫入的所有根路由鍵,並把 `[model_providers.opencodex]` 定義留在磁碟上,因此列上仍指向該提供者的對話依舊可以解析,而純 `codex` 不再指向代理。結果會回報為部分復原並列出保留的列;`ocx restore --remove-codex-provider-table` 會連這些列一併刪除,之後那些對話將無法開啟。另外,在 Codex 已把 `openai` 標記對話遷移為分頁歷史的家目錄上啟用提供者表形式的整合,過去會以 `history_paginated_openai_requires_native_writer` 整體拒絕:什麼都不寫,整合維持關閉。現在 OpenCodex 會保留受管的根 `openai_base_url` 覆寫,與 `[model_providers.opencodex]` 表並存,藉此完成這次切換。Codex 會把該覆寫合併到內建 `openai` 提供者上,所以那些對話無需重新標記即可繼續抵達代理,歷史檔案與執行緒列都不會被更動。只有需要 `x-opencodex-api-key` 准入標頭的路由形式仍會拒絕,因為 Codex 內建提供者無法攜帶該標頭;此時訊息會點名兩個可行設定——讓 Codex 走回送監聽器以便保留該覆寫,或把 `syncResumeHistory` 設為 `false`,接受那些對話轉向 Codex 自己的 OpenAI 端點。 返回根 URL 覆寫模式時,即使歷史預檢通過,OpenCodex 也會在提交設定前保留既有的 `[model_providers.opencodex]` 定義。如此一來,即使 Codex 在提交後或背景歷史工作啟動時遷移歷史格式,舊的 `opencodex` 對話仍能找到其提供者。新對話繼續使用所選的根提供者;明確要求的還原仍執行原有的獨立刪除檢查。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d928c6dd1fd..a3d94f7c734 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -827,6 +827,7 @@ "health-scoring.test.ts": "server", "history-migration-guardian.test.ts": "codex-integration", "history-ocx-compaction-recovery.test.ts": "codex-integration", + "history-paginated-openai-compat.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", "hyperbolic-provider.test.ts": "providers", diff --git a/src/cli/help.ts b/src/cli/help.ts index 38a5c18da19..67003ebca65 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -26,6 +26,8 @@ Usage: ocx stop Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) ocx restore back Re-point codex at the running proxy (undo restore) + ocx restore --remove-codex-provider-table + Also drop [model_providers.opencodex] that a paginated restore kept ocx recover-history --legacy-openai --yes Force all user-message opencodex rows to OpenAI (legacy recovery) ocx recover-history --ocx-compaction --yes diff --git a/src/cli/registry.ts b/src/cli/registry.ts index b6df952a156..af044066308 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -35,12 +35,18 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ aliases: ["eject"], usage: "ocx restore [back]", summary: "Restore native Codex config without stopping the proxy; `restore back` re-points codex at the running proxy.", + details: [ + "--remove-codex-provider-table Also remove [model_providers.opencodex] when a paginated home made restore keep it. Conversations tagged opencodex stop opening.", + ], }, { name: "eject", aliases: [], usage: "ocx eject [back]", summary: "Restore native Codex config without stopping the proxy; `eject back` re-points codex at the running proxy.", + details: [ + "--remove-codex-provider-table Also remove [model_providers.opencodex] when a paginated home made restore keep it. Conversations tagged opencodex stop opening.", + ], }, { name: "recover-history", diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 15a7e3b8344..426e70624c2 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -335,6 +335,17 @@ class CodexHistoryIntegrityError extends Error { */ export const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer"; +/** + * The narrower reason: a provider-table transition found an `openai`-tagged row Codex has + * already paginated. It is not a plain stand-down, because the transition also takes the root + * `openai_base_url` out, and that combination would send the conversation to Codex's built-in + * OpenAI endpoint rather than this proxy. + * + * A constant for the same reason as the one above: `src/codex/inject/paginated-openai-compat.ts` + * decides what to do about it, and a literal repeated in two files is how the pair drifts apart. + */ +export const HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE = "history_paginated_openai_requires_native_writer"; + function assertLegacyHistoryRecord(line: string): void { let value: unknown; try { value = JSON.parse(line); } catch { throw new CodexHistoryIntegrityError("history_rollout_record_invalid"); } @@ -459,7 +470,7 @@ export function preflightCodexHistoryInjection( } assertLegacyHistoryWritable(row.rollout_path); } - if (foundPaginatedOpenaiRow) return "history_paginated_openai_requires_native_writer"; + if (foundPaginatedOpenaiRow) return HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE; return foundPaginatedRow ? HISTORY_RELABEL_STANDS_DOWN : null; } catch (error) { return error instanceof CodexHistoryIntegrityError diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 54c83105898..f88edfe67b0 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -41,6 +41,7 @@ import { writeJournal, } from "./journal"; import { HISTORY_RELABEL_STANDS_DOWN, preflightCodexHistoryInjection } from "./history-provider"; +import { applyPaginatedOpenaiCompat } from "./inject/paginated-openai-compat"; import { describeHistoryJobFailure, deriveCodexHistoryOperation, @@ -347,12 +348,13 @@ async function injectCodexConfigImpl( // first-line repair cannot grow "openai" into "opencodex" without pre-existing padding, and // codex re-appends that stale first line whenever it writes git or memory-mode metadata. // - // Authless is excluded on purpose: its whole point is a provider that carries - // requires_openai_auth = false, and admission-token forms cannot use the root key at all. - // Those two forms therefore keep their existing behaviour, forward-tagging resume history with - // originals backed up, and that includes the case where a user enables authless and client - // compaction together. Only the compaction-only form skips the history unit. - const keepRootOverrideAlongsideTable = providerTableMode + // Authless is excluded here on purpose: its whole point is a provider that carries + // requires_openai_auth = false, so it forward-tags resume history with originals backed up + // instead, and that includes the case where a user enables authless and client compaction + // together. Only the compaction-only form skips the history unit up front. When forward + // tagging turns out to be impossible because Codex already paginated those rows, the same + // retention is selected below from the preflight verdict rather than from the routing form. + let keepRootOverrideAlongsideTable = providerTableMode && routingTarget.clientCompaction === true && routingTarget.desktopAuthless !== true && routingTarget.requiresAdmissionToken !== true; @@ -472,14 +474,15 @@ async function injectCodexConfigImpl( if (observed && observed !== HISTORY_RELABEL_STANDS_DOWN) throw new CodexHistoryPreflightRefusal(observed); return observed; }; - const observedHistoryRefusal = historyPreflight(); + const compat = applyPaginatedOpenaiCompat(historyPreflight(), routingTarget, content, eol); + content = compat.content; + keepRootOverrideAlongsideTable ||= compat.retainedRootOverride; + const observedHistoryRefusal = compat.refusal; if (observedHistoryRefusal && observedHistoryRefusal !== HISTORY_RELABEL_STANDS_DOWN) { return { success: false, historyPreflightFailureReason: observedHistoryRefusal, - message: `Codex config injection refused: ${observedHistoryRefusal}. ` - + "Existing provider definitions and conversation files were preserved. " - + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly.", + message: compat.message, }; } let historyRelabelRefusal = observedHistoryRefusal; @@ -607,11 +610,11 @@ async function injectCodexConfigImpl( atomicWriteFile(CODEX_PROFILE_PATH, profileContent); markJournalInjectedState(content, profileContent, { // A root override is ours whenever we wrote one and no user-owned value won. That is - // loopback Design B, and now also the client-compaction form, which keeps the same - // marker-owned root line beside its provider table. Journaling it matters because the - // marker comment is not durable: the Codex app can reserialize config.toml and drop - // comments, and restore then has only the journaled value to tell our line from a user's - // (#1798). The other table forms never write the key, so they still record null. + // loopback Design B, the client-compaction form, and any table form that retained the + // root line for a paginated openai row, all of which keep the marker-owned line beside + // the table. Journaling it matters because the marker comment is not durable: the Codex + // app can reserialize config.toml and drop comments, and restore then has only the + // journaled value to tell our line from a user's (#1798). Other table forms record null. injectedOpenaiBaseUrl: (providerTableMode && !keepRootOverrideAlongsideTable) || keptUserBaseUrl ? null : rootTomlString(content, "openai_base_url"), diff --git a/src/codex/inject/paginated-openai-compat.ts b/src/codex/inject/paginated-openai-compat.ts new file mode 100644 index 00000000000..eb4e3301f11 --- /dev/null +++ b/src/codex/inject/paginated-openai-compat.ts @@ -0,0 +1,90 @@ +/** + * The way forward for a provider-table transition that finds an `openai`-tagged row + * Codex has already migrated to paginated history. + * + * The refusal this resolves is correct about the danger and wrong about the remedy. + * A provider-table transition takes the root `openai_base_url` out, and a paginated row + * cannot be relabeled, so the transition as planned would send that conversation to + * Codex's built-in OpenAI endpoint. Refusing to relabel is right. Refusing the whole + * transition left #5321's reporter with 173 conversations and two unsupported exits: + * delete them, or downgrade. + * + * There is a third state, and the injector already builds it for the client-compaction + * form: keep the marker-owned root override beside the provider table. Codex merges the + * override onto its built-in `openai` entry when it builds the provider map, so the + * paginated row keeps reaching this proxy while never being rewritten. The transition + * completes, the relabel stands down, and no rollout byte or thread row is touched. + * + * Two cases cannot reach that state. An admission-token form cannot use the root key at + * all, because Codex's built-in `openai` entry carries no `x-opencodex-api-key` header; + * that one keeps the refusal and names what the operator can actually do. A root line the + * user owns is left alone, and the conversation follows their configuration rather than + * this proxy — the same guarantee the injector already makes everywhere else about a line + * it does not own. + */ +import { HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, HISTORY_RELABEL_STANDS_DOWN } from "../history-provider"; +import { applyEol, setRootOpenaiBaseUrlForTarget, stripInjectedOpenaiBaseUrl } from "./config-toml"; +import type { CodexRoutingTarget } from "./routing-target"; + +export interface PaginatedOpenaiCompatDecision { + /** The refusal that survives. `null` only when the preflight raised none. */ + readonly refusal: string | null; + /** Candidate config bytes, carrying the retained root override when one was written. */ + readonly content: string; + /** True only when OpenCodex wrote the retained line and must journal it as its own. */ + readonly retainedRootOverride: boolean; + /** What the caller reports when `refusal` is not the stand-down reason. */ + readonly message: string; +} + +function genericRefusal(refusal: string): string { + return `Codex config injection refused: ${refusal}. ` + + "Existing provider definitions and conversation files were preserved. " + + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly."; +} + +/** + * Named next actions, because "do not retry" is what trapped the reporter. Both are + * configuration the operator already owns: the loopback listener is what makes the root + * override usable, and `syncResumeHistory` is the existing opt-out from history remapping. + */ +function admissionTokenRefusal(): string { + return `Codex config injection refused: ${HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE}. ` + + "Existing provider definitions and conversation files were preserved. " + + "This home has conversations tagged openai whose history Codex has already migrated to its paginated " + + "format, and they cannot be relabeled. Keeping them on this proxy needs a root openai_base_url override, " + + "which this routing form cannot use: Codex's built-in openai provider carries no x-opencodex-api-key header. " + + "To complete the transition, either route Codex through the loopback listener " + + "(unauthenticatedLoopbackListener.enabled, or a loopback hostname) so the override can be retained, " + + "or set syncResumeHistory to false to accept that those conversations resume against Codex's own OpenAI endpoint."; +} + +/** + * Resolve the transition, retaining the root override when this routing form can own one. + * + * `content` is the fully assembled provider-table candidate: OpenCodex's own root override + * has already been stripped and not re-added, so a root `openai_base_url` still present in it + * belongs to the user. + */ +export function applyPaginatedOpenaiCompat( + refusal: string | null, + target: CodexRoutingTarget, + content: string, + eol: "\r\n" | "\n", +): PaginatedOpenaiCompatDecision { + if (refusal !== HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE) { + return { refusal, content, retainedRootOverride: false, message: refusal ? genericRefusal(refusal) : "" }; + } + if (target.requiresAdmissionToken === true) { + return { refusal, content, retainedRootOverride: false, message: admissionTokenRefusal() }; + } + const written = setRootOpenaiBaseUrlForTarget(stripInjectedOpenaiBaseUrl(applyEol(content, "\n")), target); + // The relabel still stands down either way: the row stays tagged openai and paginated, and + // the caller uses this reason to skip the history unit rather than let it start and refuse. + return { + refusal: HISTORY_RELABEL_STANDS_DOWN, + content: written.keptUserBaseUrl ? content : applyEol(written.content, eol), + retainedRootOverride: !written.keptUserBaseUrl, + message: "", + }; +} diff --git a/structure/codex-home.md b/structure/codex-home.md index c7a6ef5248f..47177b946ae 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -321,6 +321,8 @@ What a detected migration does depends on which refusal it is, and on direction. On apply, that reason retires the relabel unit and the config/profile/journal write stands when the admitted candidate preserves any existing provider table. Retention is decided before witness construction and does not depend on history preflight passing: apply keeps any existing provider definition while selecting the requested root provider. This also protects references when native migration begins after artifact commit or during worker startup, without compensating over newer native writes. Background worker failures remain reported, and candidate bytes never change after admission. Any other reason there — an unreadable state database, a changed rollout identity, a preflight that could not run — may succeed on a later attempt, so it still restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. +`history_paginated_openai_requires_native_writer` is the one apply-side reason that is neither of those. It means a provider-table transition found an `openai`-tagged row already paginated, and the transition as planned would take the root `openai_base_url` out from under it. `applyPaginatedOpenaiCompat` in `src/codex/inject/paginated-openai-compat.ts` resolves it in the same window as the provider-table retention above, before the witness: it keeps the marker-owned root override beside the table and downgrades the reason to the stand-down constant, so the relabel unit never starts and the paginated row is neither read nor written. The retained line is journaled as OpenCodex's own, which is what lets restore remove it later; a line the user owns is left in place and journaled as theirs. Only an admission-token form still refuses, because Codex's built-in `openai` entry cannot carry `x-opencodex-api-key`, and that refusal names the configuration that resolves it rather than telling the operator not to retry (#5321). + On restore and removal, that same reason no longer refuses the config half. It selects a degraded restore: every OpenCodex root routing key comes out, `[model_providers.opencodex]` is retained verbatim including its ownership marker, and the history relabel is skipped rather than attempted. The retained table is captured from the pre-transform bytes and re-appended into the same buffer, so the write is one atomic transformation — a config carrying root `model_provider = "opencodex"` without a matching table fails the whole Codex config load, not one thread, which makes that intermediate state strictly worse than the routing it replaces. `resolveRestoreHistoryDisposition` in `src/codex/inject/restore.ts` is the single place that reads the preflight reason and answers the separate question of whether routing may come out. Every other reason keeps the hard refusal and compensates on every artifact, because retiring a provider definition its thread rows still name would orphan them. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. It no longer costs the home its ability to be uninstalled: synchronous and asynchronous restore, inline journal restore, and direct config removal all take routing down on that reason while keeping the provider table, so an already-paginated home can be stopped and uninstalled and plain `codex` returns to the built-in provider. Rows naming `opencodex` still resolve through the retained table; their requests reach a proxy that is gone and fail with an ordinary connection error, which is a per-conversation failure rather than a broken config. `ocx restore --remove-codex-provider-table` removes the table for a user who accepts that those conversations stop opening; nothing selects it implicitly. diff --git a/structure/config.md b/structure/config.md index 18e805c284b..12cf9acbd10 100644 --- a/structure/config.md +++ b/structure/config.md @@ -231,9 +231,24 @@ converged and suppress the relabel permanently. That stand-down applies only when the provider tags left in place still resolve through the resulting configuration. A provider-table transition that finds a paginated `openai` row returns -`history_paginated_openai_requires_native_writer` and refuses the artifact transaction: removing -the root `openai_base_url` without relabeling that row would route a resumed conversation through -Codex's built-in OpenAI provider instead of this proxy. +`history_paginated_openai_requires_native_writer`, because removing the root `openai_base_url` +without relabeling that row would route a resumed conversation through Codex's built-in OpenAI +provider instead of this proxy. That reason selects a third state rather than a refusal: +`src/codex/inject/paginated-openai-compat.ts` keeps the marker-owned root override beside the +provider table, exactly as the client-compaction form already does, and the transition completes +with the relabel standing down. Codex merges the override onto its built-in `openai` entry when +it builds the provider map, so the row keeps reaching this proxy while never being rewritten, and +the retained value is journaled as OpenCodex's own so restore can still take it out. + +Two cases cannot reach that state. An admission-token form cannot use the root key at all — +Codex's built-in entry carries no `x-opencodex-api-key` header — so it keeps the refusal, and the +message names the two configuration keys that resolve it (`unauthenticatedLoopbackListener`, +`syncResumeHistory`) instead of saying only "do not retry". A root line the user owns is left +alone and the conversation follows the destination they chose, which is the same guarantee the +injector makes everywhere else about a line it does not own. Refusing the whole transition with +no named way forward was the 2.60.0 regression in #5321: nothing was written, the integration +stayed disabled, and the only exits a reporter could find were deleting the affected +conversations or downgrading. Rows this home tagged `opencodex` resolve through a `[model_providers.opencodex]` table. Apply retains that existing definition before building the candidate witness, even when diff --git a/tests/cli/cli-restore-back.test.ts b/tests/cli/cli-restore-back.test.ts index a750ae8ee92..a25461bd3c6 100644 --- a/tests/cli/cli-restore-back.test.ts +++ b/tests/cli/cli-restore-back.test.ts @@ -185,6 +185,14 @@ describe("ocx restore back", () => { const restoreHelp = runCli(["help", "restore"], env); expect(restoreHelp.status).toBe(0); expect(`${restoreHelp.stdout}\n${restoreHelp.stderr}`).toContain("ocx restore [back]"); + // #4812 left the escape hatch undiscoverable: the flag worked, but no help text named + // it, so a user whose paginated restore kept the provider table had nothing to read. + // Read the flag out of the parser rather than restating it, so a rename cannot pass. + const parsed = readFileSync(join(repoRoot, "src", "cli", "dispatch.ts"), "utf8") + .match(/const removeProviderTable = takeFlag\(restoreArgs, "(--[a-z-]+)"\)/)?.[1]; + expect(parsed).toBeTruthy(); + expect(`${restoreHelp.stdout}\n${restoreHelp.stderr}`).toContain(parsed!); + expect(`${usage.stdout}\n${usage.stderr}`).toContain(parsed!); } finally { removeTreeWithRetry(codexHome); removeTreeWithRetry(ocxHome); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index e88b4446d4d..0615fb2d374 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -785,14 +785,19 @@ describe("injectCodexConfig integration (Design B)", () => { expect(restoredRowBytes).toBe(rowBytes); }); - test("a provider-table transition refuses rather than strand a paginated openai thread", () => { - const original = 'model_provider = "openai"\n# >>> opencodex managed openai_base_url >>>\nopenai_base_url = "http://127.0.0.1:10100/v1"\n# <<< opencodex managed openai_base_url <<<\n'; + test("a provider-table transition keeps a paginated openai thread on the proxy instead of refusing", () => { + // #5321. The transition used to be refused outright, so nothing was written and the + // integration stayed disabled. It now completes by keeping the marker-owned root override + // beside the table: the row is never relabeled, and it still resolves to this proxy. + // The reporter's shape: a loopback root-override home turning on codexDesktopAuthless. + const original = `${OCX_ROUTING_MARKER_LINE}\nopenai_base_url = "http://127.0.0.1:10100/v1"\nmodel = "gpt-5.5"\n`; const configPath = join(codexHome, "config.toml"); writeFileSync(configPath, original); const rollout = join(codexHome, "openai-paginated.jsonl"); const bytes = JSON.stringify({ ordinal: 0, type: "session_meta", payload: { id: "fixture", history_mode: "paginated", model_provider: "openai" } }) + "\n"; writeFileSync(rollout, bytes); - const db = new Database(join(codexHome, "state_5.sqlite")); + const dbPath = join(codexHome, "state_5.sqlite"); + const db = new Database(dbPath); db.run("CREATE TABLE threads (id TEXT, rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); db.run("INSERT INTO threads VALUES ('fixture', ?, 'openai', 'paginated')", rollout); db.close(); @@ -800,10 +805,27 @@ describe("injectCodexConfig integration (Design B)", () => { const result = runInject(codexHome, ocxHome, JSON.stringify({ codexDesktopAuthless: true })); expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ - success: false, - historyPreflightFailureReason: "history_paginated_openai_requires_native_writer", + success: true, + historyPreflightFailureReason: "history_paginated_requires_native_writer", }); - expect(readFileSync(configPath, "utf8")).toBe(original); + const written = readFileSync(configPath, "utf8"); + expect(written).toContain("[model_providers.opencodex]"); + expect(written).toContain(`${OCX_ROUTING_MARKER_LINE}\nopenai_base_url = "http://127.0.0.1:10100/v1"`); + // The safety property the refusal existed to protect: the paginated row is untouched and + // still tagged openai, and the retained override is what keeps it reaching the proxy. + expect(readFileSync(rollout, "utf8")).toBe(bytes); + const after = new Database(dbPath, { readonly: true }); + expect(after.query("SELECT model_provider, history_mode FROM threads WHERE id = 'fixture'").all()) + .toEqual([{ model_provider: "openai", history_mode: "paginated" }]); + after.close(); + + // The other half of the trap (#4812): unblocking the transition is worth nothing if the + // retained override then cannot come back out. Restore journals it as ours, so it does. + const restored = JSON.parse(runRestore(codexHome, ocxHome).stdout); + expect(restored.success).toBe(true); + const native = readFileSync(configPath, "utf8"); + expect(native).not.toContain("openai_base_url"); + expect(native).not.toContain('model_provider = "opencodex"'); expect(readFileSync(rollout, "utf8")).toBe(bytes); }); diff --git a/tests/codex-integration/history-paginated-openai-compat.test.ts b/tests/codex-integration/history-paginated-openai-compat.test.ts new file mode 100644 index 00000000000..7a7f10b8901 --- /dev/null +++ b/tests/codex-integration/history-paginated-openai-compat.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { applyPaginatedOpenaiCompat } from "../../src/codex/inject/paginated-openai-compat"; +import { + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, + HISTORY_RELABEL_STANDS_DOWN, +} from "../../src/codex/history-provider"; +import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; +import type { CodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { repoPath } from "../helpers/repo-root"; + +const BASE_URL = "http://127.0.0.1:10100/v1"; +const loopback: CodexRoutingTarget = { + baseUrl: BASE_URL, + requiresAdmissionToken: false, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + desktopAuthless: true, +}; +const admissionToken: CodexRoutingTarget = { + baseUrl: "https://proxy.example/v1", + requiresAdmissionToken: true, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", +}; + +// The provider-table candidate as the injector hands it over: root model_provider plus the +// table, and no root openai_base_url, because a table transition takes that key out. +const TABLE_CANDIDATE = [ + 'model_provider = "opencodex"', + 'model = "vendor/routed-model"', + "", + "[model_providers.opencodex]", + `base_url = "${BASE_URL}"`, + "", +].join("\n"); + +describe("paginated openai compatibility (#5321)", () => { + test("a loopback table transition retains the root override and stands the relabel down", () => { + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, loopback, TABLE_CANDIDATE, "\n", + ); + // Standing down rather than clearing the reason is what keeps the caller from starting the + // relabel unit at all: the paginated row must not be rewritten, only kept resolvable. + expect(decision.refusal).toBe(HISTORY_RELABEL_STANDS_DOWN); + expect(decision.retainedRootOverride).toBe(true); + expect(decision.content).toContain(`${OCX_ROUTING_MARKER_LINE}\nopenai_base_url = "${BASE_URL}"`); + // The table has to survive alongside it, or new authless threads lose their provider. + expect(decision.content).toContain("[model_providers.opencodex]"); + expect(decision.content).toContain('model_provider = "opencodex"'); + }); + + test("the retained override is written before the first table, where Codex reads root keys", () => { + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, loopback, TABLE_CANDIDATE, "\n", + ); + const lines = decision.content.split("\n"); + expect(lines.findIndex(line => line.startsWith("openai_base_url"))) + .toBeLessThan(lines.findIndex(line => line.startsWith("["))); + }); + + test("CRLF config keeps its line endings through the retention", () => { + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, loopback, TABLE_CANDIDATE.replace(/\n/g, "\r\n"), "\r\n", + ); + expect(decision.content).toContain("\r\n"); + expect(decision.content.replace(/\r\n/g, "")).not.toContain("\n"); + }); + + test("a root line the user owns is left alone and never journaled as ours", () => { + const userOwned = `openai_base_url = "https://my-gateway.example/v1"\n${TABLE_CANDIDATE}`; + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, loopback, userOwned, "\n", + ); + // The transition still completes — the conversation follows the destination the user chose, + // which is the same guarantee the injector makes everywhere else about a line it does not own. + expect(decision.refusal).toBe(HISTORY_RELABEL_STANDS_DOWN); + expect(decision.retainedRootOverride).toBe(false); + expect(decision.content).toBe(userOwned); + }); + + test("an admission-token form keeps the refusal and names both ways out", () => { + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, admissionToken, TABLE_CANDIDATE, "\n", + ); + expect(decision.refusal).toBe(HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE); + expect(decision.retainedRootOverride).toBe(false); + expect(decision.content).toBe(TABLE_CANDIDATE); + // The regression was not the refusal, it was "do not retry" with nowhere to go. Both named + // remedies must be real configuration keys, so they are asserted against the config type. + const configSource = readFileSync(repoPath("src", "types", "config.ts"), "utf8"); + for (const key of ["syncResumeHistory", "unauthenticatedLoopbackListener"]) { + expect(decision.message).toContain(key); + expect(configSource).toContain(`${key}?:`); + } + expect(decision.message).not.toContain("do not run legacy recovery"); + }); + + test("every other reason passes through with the refusal text it always had", () => { + const decision = applyPaginatedOpenaiCompat("history_rollout_identity_changed", loopback, TABLE_CANDIDATE, "\n"); + expect(decision.refusal).toBe("history_rollout_identity_changed"); + expect(decision.retainedRootOverride).toBe(false); + expect(decision.content).toBe(TABLE_CANDIDATE); + expect(decision.message).toContain("do not run legacy recovery or retry this transition blindly"); + // A plain stand-down is not this resolver's business and must reach the caller untouched. + expect(applyPaginatedOpenaiCompat(HISTORY_RELABEL_STANDS_DOWN, loopback, TABLE_CANDIDATE, "\n")) + .toMatchObject({ refusal: HISTORY_RELABEL_STANDS_DOWN, retainedRootOverride: false, content: TABLE_CANDIDATE }); + expect(applyPaginatedOpenaiCompat(null, loopback, TABLE_CANDIDATE, "\n")) + .toMatchObject({ refusal: null, retainedRootOverride: false, content: TABLE_CANDIDATE }); + }); + + test("the refusal code is defined once and read from that definition", () => { + // The pair drifted once already between apply and restore. A literal in a second file is + // how it drifts again, so the only occurrences allowed are the constant and its consumers. + const provider = readFileSync(repoPath("src", "codex", "history-provider.ts"), "utf8"); + const occurrences = provider.split(HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE).length - 1; + expect(occurrences).toBe(1); + expect(readFileSync(repoPath("src", "codex", "inject", "paginated-openai-compat.ts"), "utf8")) + .not.toContain(`"${HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE}"`); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 11be2c22de7..ab4f3350083 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -654,6 +654,7 @@ "health-scoring.test.ts": "server", "history-migration-guardian.test.ts": "codex-integration", "history-ocx-compaction-recovery.test.ts": "codex-integration", + "history-paginated-openai-compat.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", "hyperbolic-provider.test.ts": "providers", From 16cb65b4146936a90d22c2e31a783f8b95c47a77 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:06:34 +0900 Subject: [PATCH 38/87] Rework #4942 and #4989 into one ambiguous-resend gate with one grant per request (#5342) * feat(lib): one gate and one grant for an ambiguous resend far the caller observed the exchange and why it failed. For a stage whose commitment is nothing-observed and a cause whose evidence is unknown it answers refused-ambiguous, and it names the only thing that may override that answer: a narrowly scoped recovery a maintainer opted into and bounded. Two separate overrides is one too many. A request that resets before the response head and again after it would buy a replacement send on each side, and the second one is exactly the duplicated inference the refusal exists to prevent. request-resend-gate.ts is the single place the override is applied. It derives stage, cause, permission and send class from request-failure-model.ts and adds nothing of its own except the grant, which it claims at the moment it authorises rather than earlier -- so a caller cannot ask without paying, and a committed or futile failure refuses without draining the replacement a later ambiguous reset would have been entitled to. The cause can be asked in terms of the AttemptRecoveryKind the send will be recorded as, which is what keeps the reason in the log and the reason the gate weighed from being two different values. The grant itself lives on the request's execution budget, beside the physical-send ledger, because it has to be shared in exactly the same places: a combo child derives its own budget from the parent's ledger, and two counters would let one logical request replace an unknown-state send twice. It is not a send budget -- an authorised replacement still has to fit inside remainingBaseSends like everything else. Registers the three test files this branch adds in both the layout map and the independent expectation fixture. * feat(responses): replace an ambiguous native Responses send through the shared gate #4942 and #4989 arrived as two features and are one. Both ask whether a native Responses send that failed with the caller having observed nothing may be sent again; they differ only in where they ask it. #4942 asks before any response head, #4989 after a head whose SSE body carried only control events. Against the landed stage table those are the same row, so this is one rework rather than two merged branches. The provider opts in with providers..retryOnReset, the request has to be one reset-replay.ts can judge self-contained -- store: false, complete input, no server-side continuation state, only client-executed tools -- and the whole logical request holds one replacement grant, whichever stage asks for it. replacements counts duplicate inferences the operator accepts, not retries and not sends, which is why its ceiling is two rather than a send budget. Pre-header: fetchWithResetRetry takes a claim callback rather than a count. A count handed to each leg is a count each leg holds, and the rotation, refresh and same-target 429 legs all carry the same turn. Once a replacement has gone out the leg can only settle as the refusal -- including when a later attempt fails some other way, because throwing there becomes a 502 at the caller and a 502 is what the Codex client re-sends four more times. The 401 replay leg is routed through the same helper for exactly that reason; it used to reject straight into that path. Post-header: the SSE preflight now reports the stage it observed rather than a boolean, and the gate decides. headers-only before any parsed event, protocol-prelude after response.created, semantic-output once anything else arrives -- including a payload the inspector could not parse, because an unreadable frame may be output. #4989 required response.created; the table gives headers-only the same commitment and therefore the same answer, so it is admitted rather than refused. A response.created whose snapshot already carries output items is not a prelude. The replacement send is charged to the same request counter every other send uses and recorded with the kind the gate derived its cause from, so one authorisation is one reason and one send. The deferred preflight only wraps a body when the provider opted in, so a proxy that configures nothing buffers nothing and its first byte is unchanged. Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> Co-authored-by: lidge-jun * docs: record the ambiguous-resend gate and what the R4 remainders reach structure/transports/responses.md said a pre-header reset is always the terminal refusal and that only a replay-safe operation opts into reset retries. Both sentences are now wrong in the same place, so the reset-retry section gains the gate beside it, the combo streaming boundary says that native post-header recovery shares the same reader and reports a stage, and the core-module table gains reset-replay.ts and the grant it hands to request-send-budget.ts. docs-site documents retryOnReset as a provider field in the English source and in all seven locale tables, and the server reference paragraph that explains the 429 refusal now says how an operator opts out of it and what the grant covers. The devlog records the two remainders honestly. Neither #4191 nor #5180 is reached by this gate, and both investigations found something worth not losing: the WebSocket stage projection reports semantic-output for a failure carrying only response.created, and the #5180 symptom is a missing policy default plus a cooldown a single-key provider cannot currently write. * test(responses): count the post-header replacement as a transient-retry send site The source oracle balanced `fetchWithTransientRetry(` occurrences against the call sites that take `attempts` from the provider resolver. The post-header replacement reaches upstream through `refetchAfterProtocolSafeReset` instead, so it drew on the resolver without being counted as a site and the equality broke at 6 against 5. Counting both helpers keeps the equality exact and widens what it protects: a second send helper added on the fixed constant now fails here rather than balancing silently. * fix(responses): make the self-contained judgment reach the allowance `ambiguousResendAllowanceFor` declared its second parameter as `unknown` and handed it straight to `selfContainedResponsesBody`, while the dispatch site passed the memoized predicate. A function is not a record, so the judgment was always false and every opted-in reset refused as `ambiguous-request-not-replayable`. The feature was inert and nothing in the transport tests could see it, because they never reach the body judgment. The parameter is now `() => boolean` and the property is a getter, so the laziness the call site wanted is real and passing a body instead of a predicate is a typecheck failure rather than a silent false. Also stop cancelling the original body from the deferred wrapper once the preflight owns its reader: that body is locked, so the cancellation rejected and was swallowed. `initialize` already releases whichever body it selected when it observes a cancelled downstream, and that is the one that has to be let go. * test(responses): declare reset-replay.ts as an extracted owner responses-core-modules.test.ts derives the owner graph from the source imports and compares it to the inventory. A new sibling under src/server/responses/ has to be in one of the two lists or the comparison fails, which is the point: a new owner must not disappear from source-oracle coverage by being absent. It belongs in the inventory rather than the separately-owned boundary set, because structure/transports/responses.md already lists it in the per-request core-module ownership table. The 2000-line coverage now applies to it too, and passthrough-dispatch.ts remains the largest owner at 1762. * test(responses): pin what a committed stream actually does at a read error The new case asserted that a read error after output commits reports `semantic-output`. It cannot: `preflightComboStreamResponse` returns the body as `accepted` the moment output commits, so the error happens on the caller's side of the boundary and no stage is ever reported. Assert that instead, which is the stronger safety statement -- a committed stream never reaches the resend gate at all, rather than reaching it and being refused there -- and keep the prefix and the original error observable to whoever reads the returned body. The stage helper stays total, with a note that its committed branches exist so a later change to that loop cannot promote a committed stream by omission. * test(responses): prove the resend boundary where it is enforced The stage a read error is reported at is only half the guarantee. What decides permission is that a stream which committed output never gets a replacement offered at all, and the seam that decides it is the deferred wrapper rather than the preflight. Assert it there: a prelude-only stream consults the recovery callback exactly once and at a stage whose `stageCommitment` is `nothing-observed`, and an output-bearing stream never consults it. The commitment is read from the failure model instead of compared against a written-out stage name, so a stage added to the model later cannot pass this by being unlisted. * test(layout): name the gate test so its seed does not contradict its domain The layout map's regex seeds place a new test file on the day it is added, and the tooling oracle fails when a seed disagrees with the explicit entry, because that seed would put the next similarly named file in the wrong directory. `request-` seeds to `usage`, so `request-resend-gate.test.ts` pointed there while the explicit table said `lib`. Renamed rather than pinned: `pinnedOverrides` is for the historical files whose name says one thing and whose imports say another, not a place to park a file added today. `ambiguous-resend-gate` matches no seed, which is the case the oracle tolerates, and it says what the gate is about -- the ambiguous row of the stage table, which is precisely not the transient one. Updates both layout maps and the INV-RESEND-02 binding in structure/overview.md. --------- Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> --- .../040_r4_retry_rework.md | 53 +++++ .../050_r4_remainders.md | 59 +++++ .../fr/reference/configuration/providers.md | 1 + .../ja/reference/configuration/providers.md | 1 + .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 1 + .../docs/reference/configuration/server.md | 11 + .../ru/reference/configuration/providers.md | 1 + .../tr/reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 5 +- src/config.ts | 2 +- src/config/load-degrade.ts | 41 +++- src/config/schema/leaf-validators.ts | 15 ++ src/lib/request-execution-budget.ts | 31 +++ src/lib/request-resend-gate.ts | 138 +++++++++++ src/lib/upstream-retry.ts | 133 +++++++++-- src/providers/key-failover.ts | 32 ++- src/server/auth-cors.ts | 10 + src/server/relay.ts | 13 ++ .../responses/combo-stream-preflight.ts | 174 +++++++++++++- src/server/responses/fetch-helpers.ts | 11 +- src/server/responses/passthrough-dispatch.ts | 214 +++++++++++++++--- src/server/responses/request-send-budget.ts | 12 + src/server/responses/reset-replay.ts | 108 +++++++++ src/types.ts | 1 + src/types/provider.ts | 31 +++ structure/overview.md | 7 + structure/transports/responses.md | 39 +++- tests/fixtures/test-layout-expected.json | 5 +- tests/helpers/responses-core-source.ts | 1 + tests/lib/ambiguous-resend-gate.test.ts | 172 ++++++++++++++ tests/lib/execution-budget-permits.test.ts | 35 +++ tests/lib/upstream-retry.test.ts | 95 ++++++++ ...onses-passthrough-transient-policy.test.ts | 7 +- .../responses/responses-reset-replay.test.ts | 122 ++++++++++ tests/routing/combo-stream-preflight.test.ts | 97 +++++++- .../management-provider-reset-replay.test.ts | 51 +++++ 39 files changed, 1659 insertions(+), 74 deletions(-) create mode 100644 devlog/_plan/260920_round2_followups/040_r4_retry_rework.md create mode 100644 devlog/_plan/260920_round2_followups/050_r4_remainders.md create mode 100644 src/lib/request-resend-gate.ts create mode 100644 src/server/responses/reset-replay.ts create mode 100644 tests/lib/ambiguous-resend-gate.test.ts create mode 100644 tests/responses/responses-reset-replay.test.ts create mode 100644 tests/server/management-provider-reset-replay.test.ts diff --git a/devlog/_plan/260920_round2_followups/040_r4_retry_rework.md b/devlog/_plan/260920_round2_followups/040_r4_retry_rework.md new file mode 100644 index 00000000000..6022b10626a --- /dev/null +++ b/devlog/_plan/260920_round2_followups/040_r4_retry_rework.md @@ -0,0 +1,53 @@ +# R4 — #4942 and #4989 as one ambiguous-resend gate + +Status: OPEN. Branch `codex/260920-r4-retry-rework`, cut from `origin/dev` at `d6d87440b7`. + +## Why the two pull requests are one change + +#4942 (FredAmartey) replays a native Responses send whose connection died before any response +head, behind a per-provider opt-in. #4989 (lidge-jun) replaces a native Responses SSE stream that +died after the head while the body had carried only control events. Written apart they read as two +features. Against the stage table #5266 landed in `src/lib/request-failure-model.ts` they are one +row: a stage whose `stageCommitment` is `nothing-observed`, with a cause whose +`causeEvidence` is `unknown`. `resendPermission` answers `refused-ambiguous` for both, and +the module already names the only thing that may override it — "a narrowly scoped, explicitly +opted-in recovery that a maintainer reasoned about and bounded". + +Two overrides is one too many. #4942 spreads `replayResets: 2` into every dispatch leg of the +request and #4989 takes `Math.min(1, remaining)` of the transient budget at the stream boundary, +so one logical request that reset before the head and again after it would buy a replacement on +each. The rework gives the override a single per-request allowance and makes both stages claim +from it. + +## Shape + +- `src/lib/request-resend-gate.ts` — the one gate. Pure table lookup for the stages the caller + already observed something at, plus the operator override for the ambiguous row. It never + restates the table: stage, cause, permission and send class all come from + `request-failure-model.ts`, and the cause comes from the `AttemptRecoveryKind` that will be + recorded, so the reason in the log and the send it authorised cannot disagree. +- `src/lib/request-execution-budget.ts` — the allowance lives on the shared send ledger, which is + what a combo child inherits through `deriveRequestExecutionBudget`. Parent and child therefore + cannot each hold one. +- `src/server/responses/reset-replay.ts` — the provider opt-in and the body judgment from #4942, + plus the per-request authority both call sites use. +- `src/lib/upstream-retry.ts` — the pre-header claim, as a callback rather than a number. +- `src/server/responses/combo-stream-preflight.ts` — the preflight reports the stage it observed + instead of a boolean, so the gate rather than the preflight decides. + +## Stage classification at the stream boundary + +#4989 gated on `responseCreated && !outputCommitted && !terminal`. That is `protocol-prelude`. +A read error before any parsed event is `headers-only`, which the table gives the same +commitment and therefore the same answer; the rework admits it rather than refusing a row the +table permits. Everything else the preflight can see is `semantic-output` or `terminal`, and +those refuse regardless of cause. + +## In scope from the remainders + +#4191 and #5180 only to the extent the resend gate reaches them. Recorded in 050. + +## Verification + +Static review plus exact-head hosted CI. Local suites, individual tests, typecheck, build, +install and live `ocx` execution are NOT RUN by lane policy. diff --git a/devlog/_plan/260920_round2_followups/050_r4_remainders.md b/devlog/_plan/260920_round2_followups/050_r4_remainders.md new file mode 100644 index 00000000000..4c826a9d172 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/050_r4_remainders.md @@ -0,0 +1,59 @@ +# R4 — what the remainders reach, and what they do not + +The lane brief put #4191 and #5180 in R4 "as far as the rework reaches". This records where that +line actually fell, with the evidence, so the next lane starts from a finding rather than a +re-investigation. + +## #4191 — reached: nothing. Found: a wrong stage in the shared vocabulary + +The resend gate does not consult the WebSocket projection. The post-header path excludes a +`isCodexWsUpstreamResponse` body on purpose: the WS transport settles its own ambiguous +failures and marks them non-replayable, and a second reader of one exchange is a defect, not a +recovery. So the durable threading the round-2 plan names is untouched here. + +The investigation did surface a real defect in the projection itself. +`classifyCodexWsFailure` in `src/server/responses/codex-ws-wire.ts` returns +`after-response-started` — which `CODEX_WS_FAILURE_PROJECTION` maps to `semantic-output` — +as soon as `relayedEvents > 0`. But `src/server/responses/codex-ws-exchange.ts` increments +`relayedEvents` for every non-metadata Responses event, and `response.created` is one: +`controlFrame` is set only when the metadata channel consumes the frame, not for lifecycle +events. `src/lib/request-failure-model.ts` puts `response.created` in `protocol-prelude` +and requires an output-bearing event for `semantic-output`. A WS failure carrying only a +created event therefore projects as committed output today. + +It is left here rather than fixed because the fix needs a counter the classifier does not have, +and `CodexWsStageRecord` is derived from `CodexWsFailureStage` by `Omit`, so adding one +lands in a persisted record whose read-back whitelist in `src/usage/log.ts` would reject every +row written before it. Adding the counter and `Omit`-ing it from the durable twin avoids that, +but the output-bearing predicate lives in `combo-stream-preflight.ts` and restating it in the +exchange is the class of duplication this round already paid for three times. It belongs with +the lane that threads `failureStage` / `failureCause` into the record, where both halves can +be written once. + +The SSE fallback the issue asks for stays out regardless. After `ws.send()` returns, a +fallback is a second physical send on another transport, which is a transport decision with its +own duplicate-inference policy — not a retry-gate change. + +## #5180 — reached: nothing. The symptom is upstream of this gate + +The reported failure is a key-auth `openai-chat` provider answering a bare 429. Traced on +current `dev`: `rateLimitRetryPolicyFor` returns null for every provider except the +OpenCode Go destination, so the same-target wait never runs; key rotation needs a pool of at +least two; and `fetchWithResetRetry` returns the first received HTTP response without +consulting its status. One send, 429 returned, which is exactly what the reporter saw. +`Retry-After` is forwarded to the client — synthesized as `2` for a bare retryable 429 by +`src/lib/retry-after.ts` — but the proxy never waits on it itself. + +None of that is an ambiguous-resend question: a received 429 is `headers-only` with cause +`rate-limit`, which the stage table already answers `permitted` and funds from the +`transient` class. It needs no grant and no override. What it needs is a policy default and a +process-wide cooldown that a single-key provider can write, and `keyCooldowns` cannot be +reused unchanged because both its identity and its write path require a multi-key pool. + +One adjacent accounting gap is worth recording for whoever takes it. On the generic adapter +path, `prepareAdapterExchange` passes `attempts` and `onSendsConsumed` to its retry helper +only when `transientRetryOn5xx` is configured. An unconfigured provider's initial send is +therefore recorded in the attempt log but never charged to the request-wide send counter. It is +bounded today — without `replaySafe` the reset helper makes exactly one send — so it is an +under-count rather than an amplification, and widening it without a suite to run is not a change +worth making blind. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index e96e79f5f20..45ab2192c50 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -139,6 +139,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `responsesSnapshotRepair?` | `boolean` | Réparation côté client désactivée par défaut pour les instantanés du cycle de vie des réponses clairsemés dans SSE et JSON. Remplit les métadonnées d'état canonique, de sortie et d'outil manquantes tandis que l'inspection brute et la persistance restent inchangées. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Fournisseurs à clé API uniquement (`authMode: "key"`). Nouvelle tentative facultative sur la même cible après un 429 : lorsque `retryOn429` est absent, la fonctionnalité est désactivée ; la présence d'un objet l'active, sauf avec `enabled: false`. Après un 429, le proxy attend selon `Retry-After` reçu en amont ou selon l'intervalle fixe, puis relit la requête à l'identique avec la même clé avant tout basculement de clé. Ce comportement couvre la boucle principale de récupération d'un tour textuel, le protocole de transfert Responses, le pont d'images et de vidéos, le service auxiliaire de recherche Web et les continuations du terminal. Seules les réponses HTTP 429 reçues avant le début de la diffusion peuvent être relues ; les transports `runTurn` personnalisés ne font pas partie de la boucle de nouvelle tentative HTTP. `attempts` compte les relectures avec la même clé après le premier 429, soit `attempts` + 1 envois au total, et constitue un budget commun à toute la requête, partagé entre la boucle principale de récupération, la continuation de la garde du terminal et les nouvelles tentatives du pont. L'épuisement de `attempts` arrête uniquement les relectures supplémentaires avec la même clé : le basculement normal de clé ou la gestion de l'erreur finale s'applique ensuite selon les cibles disponibles. Sur le protocole de transfert authentifié par clé, aucun basculement n'est possible ; le 429 final est donc renvoyé sans modification. Codex ne retente jamais lui-même une requête après un 429 : cette option constitue ainsi la seule protection pour les fournisseurs à clé unique. Valeurs par défaut : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (chaque attente est plafonnée à `maxIntervalMs`, lui-même plafonné à 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Fournisseurs `openai-chat` et `openai-responses` authentifiés par clé uniquement. Les fournisseurs `authMode: "forward"` (le pool de comptes ChatGPT) ne lisent jamais cette option et conservent l'échelle par défaut. Nouvelle tentative facultative pour les états transitoires reçus en amont avant le début de la diffusion (500, 502, 503, 504, 520, 521, 522) : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Ce comportement couvre la requête Responses initiale, la continuation de la garde du terminal, le point de terminaison natif `/v1/chat/completions` et les réémissions liées à la récupération après un 429 ou à la récupération de compte. `attempts` représente le nombre TOTAL d'envois en amont autorisés pour une requête, premier envoi compris (de 1 à 10, valeur par défaut : 3). Il constitue un budget commun à la requête, partagé avec la récupération après une réinitialisation de connexion ; ainsi, `3` signifie qu'au plus trois requêtes réelles atteignent le fournisseur. Les attentes utilisent une temporisation exponentielle à base fixe de 400 ms, plafonnée à 5 s, et respectent `Retry-After`. Cette option est distincte de `retryOn429`, qui traite la limitation de débit ; les échecs en cours de diffusion ne sont jamais relus. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs uniquement, `authMode: "forward"` compris. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. | | `autoToolChoiceOnlyModels?` | `string[]` | Modèles dont `tool_choice` accepte uniquement `auto` ou `none` ; les choix forcés sont dévalorisés. | | `preserveReasoningContentModels?` | `string[]` | Modèles nécessitant un assistant préalable `reasoning_content` dans l'historique des discussions. | | `reasoningDetailsModels?` | `string[]` | Modèles dont le point de terminaison renvoie la réflexion sous forme de tableau structuré `reasoning_details` (MiniMax série M avec `reasoning_split`) ; les deltas de flux sont des instantanés cumulatifs comparés par préfixe, et la réflexion conservée est rejouée sous forme de tableau `reasoning_details` plutôt que de chaîne `reasoning_content`. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 6a2364e23c0..bac2502e0ec 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -131,6 +131,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | キー認証の `openai-chat` および `openai-responses` プロバイダーのみ。`authMode: "forward"` のプロバイダー(ChatGPT アカウントプール)はこのオプションを読まず、既定の再試行段数を維持します。ストリーム開始前に上流から返される一時的なステータス(500、502、503、504、520、521、522)に対するオプトインの再試行です。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。最初の Responses リクエスト、ターミナルガード継続、ネイティブの `/v1/chat/completions`、および 429/アカウント回復時の再取得が対象です。`attempts` は最初の送信を含め、1 回のリクエストで許可される上流への送信総数です(1~10、デフォルトは 3)。接続リセット回復と共有するリクエスト単位の単一予算であるため、`3` を指定した場合、プロバイダーに到達する実リクエストは最大 3 回です。待機には 400 ms を基準とする固定式の指数バックオフを使用し、上限は 5 秒で、`Retry-After` に従います。レート制限を扱う `retryOn429` とは別の機能であり、ストリーム開始後の失敗は再送されません。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー専用で、`authMode: "forward"` も含みます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 | | `preserveReasoningContentModels?` | `string[]` |チャット履歴に以前のアシスタント `reasoning_content` が必要なモデル。 | | `reasoningDetailsModels?` | `string[]` | thinking を構造化された `reasoning_details` 配列で返すモデル(`reasoning_split` 使用の MiniMax M シリーズ)。ストリーム差分は累積スナップショットとして prefix-diff され、保持された reasoning は `reasoning_content` 文字列ではなく `reasoning_details` 配列としてリプレイされます。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 824a9826940..8fe624c51c9 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -131,6 +131,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 키 인증 `openai-chat` 및 `openai-responses` 프로바이더 전용입니다. `authMode: "forward"` 프로바이더(ChatGPT 계정 풀)는 이 옵션을 읽지 않고 기본 재시도 단계를 유지합니다. 스트림 시작 전의 일시적인 업스트림 상태(500, 502, 503, 504, 520, 521, 522)를 선택적으로 재시도합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 최초 Responses 요청, 터미널 가드 연속 요청, 네이티브 `/v1/chat/completions`, 429/계정 복구 재조회를 포함합니다. `attempts`는 최초 전송을 포함하여 요청 하나에 허용되는 업스트림 전송의 총횟수(1..10, 기본값 3)입니다. 연결 재설정 복구와 요청 단위 예산 하나를 공유하므로 `3`이면 실제로 프로바이더에 도달하는 요청은 최대 세 번입니다. 대기에는 400ms로 고정된 지수 백오프를 사용하고 상한은 5초이며 `Retry-After`를 따릅니다. 속도 제한을 처리하는 `retryOn429`와는 별개이며, 스트림 도중의 실패는 절대 재전송하지 않습니다. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 네이티브 `openai-responses` 프로바이더 전용이며 `authMode: "forward"`도 포함합니다. 호출자가 아무것도 관측하지 못한 채 실패한 전송을 선택적으로 대체합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 응답 헤더가 오기 전에 연결이 끊어진 경우와, 헤더 이후 SSE 본문이 제어 이벤트만 실은 채 끊어진 경우를 모두 다룹니다. 자체 완결된 요청만 대체합니다. `store: false`, 완전한 `input`, `previous_response_id`·`conversation`·`stream_id` 없음, 클라이언트가 실행하는 도구만 해당합니다. `replacements`는 모든 구간과 모든 콤보 자식을 합쳐 논리 요청 하나가 만들 수 있는 대체 전송 횟수입니다(1..2, 기본값 1). 구간별 재시도 횟수도 전송 예산도 아니므로, 대체 전송도 해당 구간이 이미 가진 전송 허용량 안에 들어가야 합니다. 이미 출력이나 도구 호출을 내보낸 요청은 이 값과 무관하게 대체하지 않습니다. 원본 전송이 이미 시작됐다면 대체한 추론도 과금될 수 있어서 기본값은 꺼짐입니다. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | | `preserveReasoningContentModels?` | `string[]` | chat 기록에서 이전 assistant `reasoning_content`가 필요한 모델입니다. | | `reasoningDetailsModels?` | `string[]` | thinking을 구조화된 `reasoning_details` 배열로 반환하는 모델(`reasoning_split` 사용 MiniMax M 시리즈). 스트림 델타는 누적 스냅샷이라 prefix-diff로 처리하고, 보존된 reasoning은 `reasoning_content` 문자열 대신 `reasoning_details` 배열로 리플레이합니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 6f780ed4ed6..3cae671a466 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -212,6 +212,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). The search model comes from `webSearchSidecar.model` only when `webSearchSidecar.backend` resolves to the same backend this bridge names; otherwise the bridge runs that backend's own default, because a model chosen for one vendor is rejected by another. An unset `webSearchSidecar.backend` resolves to `openai`, so an unset-backend model reaches an `openai` bridge and no other. There is no per-provider bridge model override. Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Key-auth `openai-chat` and `openai-responses` providers only. `authMode: "forward"` providers (the ChatGPT account pool) never read this option and keep the default ladder. Opt-in retry for pre-stream transient upstream statuses (500, 502, 503, 504, 520, 521, 522): absent means off, object presence enables it unless `enabled: false`. Covers the initial Responses request, the Responses passthrough lane and each of its recovery legs (OAuth-401 replay, same-target 429 replay, validated rebuild), the terminal-guard continuation, and native `/v1/chat/completions`. `attempts` is the TOTAL number of upstream sends allowed for one request including the first (1..10, default 3) — it is one budget shared with connection-reset recovery, so `3` means at most three real requests reach the provider. On the Responses passthrough lane the configured value is additionally intersected with the request-wide send allowance, so a value below that allowance narrows the ladder exactly while a value above it does not raise the bound. Waits use a fixed 400 ms exponential backoff capped at 5 s and honor `Retry-After`. Separate from `retryOn429`, which handles rate limiting; mid-stream failures are never replayed. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Native `openai-responses` providers, including `authMode: "forward"`. Opt-in replacement of a send that failed while the caller had observed nothing: absent means off, object presence enables it unless `enabled: false`. Covers both ambiguous stages — a connection that died before any response header, and an SSE body that died after the header while carrying only control events. Only a self-contained request is ever replaced: `store: false`, complete `input`, no `previous_response_id`, `conversation` or `stream_id`, and only client-executed tools. `replacements` is the number of replacement sends ONE logical request may make across every leg and every combo child (1..2, default 1) — not a per-leg retry count and not a send budget, so a replacement still has to fit inside the send allowance the leg already had. A request that already emitted output or a tool call is never replaced, whatever this is set to. The replacement inference may still be billed if the origin had already started the first one, which is why this is off by default. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | | `reasoningDetailsModels?` | `string[]` | Models whose endpoint returns thinking as a structured `reasoning_details` array (MiniMax M-series with `reasoning_split`); stream deltas are cumulative snapshots that are prefix-diffed, and preserved reasoning replays as a `reasoning_details` array instead of a `reasoning_content` string. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 6deae1998c5..19b3bb2f002 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -68,6 +68,17 @@ it, nor does it record the refusal as rate-limit or quota evidence against the c was holding. Tool-call side requests such as vision and web search are replayed normally, because repeating them cannot duplicate a turn. +A native Responses provider can opt into replacing that send with +[`retryOnReset`](providers.md#provider-entries-ocxproviderconfig). The same grant covers the +case where the connection survives the header and the SSE body then dies carrying only control +events, because the caller has observed nothing in either one. A replacement happens only when +the request is self-contained (`store: false`, complete input, client-executed tools only, no +server-side continuation state), and one logical request gets the configured number of +replacements in total — across every recovery leg and every combo child, not one each. The +refusal returns as soon as that grant is spent, the leg has no send left, or a replacement fails +for any other reason. A request that already emitted output or a tool call keeps the refusal +regardless. A caller that cancels mid-replacement gets the cancellation, not the refusal. + `noProxy` accepts either a comma-separated string or an array. Both forms add entries without replacing an inherited `NO_PROXY`: diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 7107a9cb120..6c683c8b51f 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -144,6 +144,7 @@ cross-route credential fallback не существует. Строки API GPT- | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только для провайдеров с API-ключом (`authMode: "key"`). Опциональный повтор при 429 на том же таргете: если `retryOn429` отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. При 429: ожидание (`Retry-After` апстрима или фиксированный интервал) и повтор идентичного запроса на том же ключе до любого фейловера ключей — покрывает основной цикл восстановления текстовых ходов, passthrough-канал Responses, мост изображений/видео, sidecar web-search и терминальные продолжения. Повтор допустим только для HTTP 429, полученных до начала потока; пользовательские транспорты `runTurn` не входят в цикл HTTP-повторов. `attempts` — это число повторов на том же ключе после первого 429 (всего отправок = `attempts` + 1) и единый бюджет на запрос, общий для основного цикла восстановления, терминального продолжения и повторов моста. Исчерпание `attempts` лишь останавливает дальнейшие повторы на том же ключе; далее применяется обычный фейловер ключей или финальная обработка ошибки в зависимости от доступных таргетов — на passthrough-канале с ключевой аутентификацией фейловера нет, поэтому исчерпанный 429 возвращается как есть. Codex сам никогда не повторяет 429, поэтому это единственная защита для провайдеров с одним ключом. По умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (любое ожидание ограничено `maxIntervalMs`, который сам ограничен 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Только для провайдеров `openai-chat` и `openai-responses` с аутентификацией по ключу. Провайдеры с `authMode: "forward"` (пул аккаунтов ChatGPT) никогда не читают эту настройку и сохраняют число повторов по умолчанию. Опциональный повтор при временных статусах апстрима до начала потока (500, 502, 503, 504, 520, 521, 522): если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает исходный запрос `Responses`, продолжение терминального предохранителя, нативный `/v1/chat/completions`, а также повторные запросы при восстановлении после 429 или ошибки учётной записи. `attempts` — ОБЩЕЕ число разрешённых отправок в апстрим для одного запроса, включая первую (1..10, по умолчанию 3). Это единый бюджет на запрос, общий с восстановлением после сброса соединения, поэтому `3` означает, что до провайдера дойдут не более трёх реальных запросов. Ожидание использует экспоненциальную задержку с фиксированной начальной величиной 400 мс, ограниченную 5 с, и учитывает `Retry-After`. Параметр не связан с `retryOn429`, который обрабатывает ограничение частоты запросов; сбои после начала потока никогда не воспроизводятся. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Только для нативных провайдеров `openai-responses`, включая `authMode: "forward"`. Необязательная замена отправки, которая завершилась неудачей, когда вызывающая сторона ещё ничего не наблюдала: если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает обе неоднозначные стадии — обрыв соединения до любого заголовка ответа и обрыв тела SSE после заголовка, когда оно несло только управляющие события. Заменяется только самодостаточный запрос: `store: false`, полный `input`, отсутствие `previous_response_id`, `conversation` и `stream_id`, и только инструменты, исполняемые клиентом. `replacements` — число замещающих отправок, которые ОДИН логический запрос может сделать по всем участкам и всем дочерним запросам комбо (1..2, по умолчанию 1). Это не число повторов на участок и не бюджет отправок, поэтому замена всё равно должна поместиться в уже имеющийся у участка лимит отправок. Запрос, который уже выдал вывод или вызов инструмента, не заменяется никогда, каким бы ни было это значение. Замещающий вывод модели всё равно может быть оплачен, если источник уже начал первый, поэтому параметр выключен по умолчанию. | | `autoToolChoiceOnlyModels?` | `string[]` | Модели, у которых `tool_choice` принимает только `auto` или `none`; forced choice понижается. | | `preserveReasoningContentModels?` | `string[]` | Модели, которым нужен предыдущий assistant `reasoning_content` в chat history. | | `reasoningDetailsModels?` | `string[]` | Модели, чей endpoint возвращает thinking как структурированный массив `reasoning_details` (MiniMax M-series с `reasoning_split`); потоковые дельты — кумулятивные снимки, сравниваемые по префиксу, а сохранённый reasoning воспроизводится массивом `reasoning_details` вместо строки `reasoning_content`. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 5a0c198d754..1028d5ee95a 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -145,6 +145,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Yalnızca API anahtarı sağlayıcıları (`authMode: "key"`). İsteğe bağlı aynı hedef 429 yeniden denemesi: `retryOn429` olmadığında özellik kapalıdır; nesnenin varlığı `enabled: false` olmadığı sürece özelliği etkinleştirir. 429'da proxy bekler (yukarı akış `Retry-After` veya sabit aralık) ve herhangi bir anahtar yük devretmesinden önce aynı istek üzerinde aynı anahtarla aynı isteği yeniden oynatır — ana metin turu kurtarma döngüsü, Responses doğrudan geçiş hattı, görsel/video köprüsü, web araması sidecar'ı ve terminal devamları genelinde. Yalnızca akış öncesi HTTP 429 yanıtları yeniden oynatma için uygundur; özel `runTurn` aktarımları HTTP yeniden deneme döngüsünün dışındadır. `attempts`, ilk 429'dan sonraki aynı anahtar yeniden oynatmalarını sayar (toplam gönderim = `attempts` + 1) ve ana kurtarma döngüsü, terminal koruma devamı ve köprü yeniden denemeleri tarafından paylaşılan tek bir istek genelinde bütçedir. `attempts`'ı tüketmek yalnızca daha fazla aynı anahtar yeniden oynatmasını durdurur: normal anahtar yük devretmesi veya nihai hata işleme daha sonra kullanılabilir hedeflere göre geçerli olur — anahtar kimlik doğrulamalı doğrudan geçiş hattında yük devretme yoktur, bu nedenle tükenen 429 olduğu gibi görünür. Codex'in kendisi 429'u asla yeniden denemez, bu nedenle tek anahtarlı sağlayıcılar için tek savunma budur. Varsayılanlar: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (tek bir bekleme `maxIntervalMs` ile sınırlandırılır, kendisi de 600000 ile sınırlandırılır), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Yalnızca anahtarla kimlik doğrulanan `openai-chat` ve `openai-responses` sağlayıcıları. `authMode: "forward"` sağlayıcıları (ChatGPT hesap havuzu) bu seçeneği hiç okumaz ve varsayılan merdiveni korur. Akış öncesi geçici yukarı akış durumları (500, 502, 503, 504, 520, 521, 522) için isteğe bağlı yeniden deneme: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İlk Responses isteğini, terminal koruma devamını, yerel `/v1/chat/completions` isteklerini ve 429/hesap kurtarma yeniden getirmelerini kapsar. `attempts`, bir istek için ilk gönderim dahil izin verilen yukarı akış gönderimlerinin TOPLAM sayısıdır (1..10, varsayılan 3) — bağlantı sıfırlama kurtarmasıyla paylaşılan, istek kapsamlı tek bütçedir; dolayısıyla `3`, sağlayıcıya en fazla üç gerçek isteğin ulaşması anlamına gelir. Beklemelerde 400 ms'lik sabit üstel geri çekilme uygulanır, süre 5 sn ile sınırlandırılır ve `Retry-After` dikkate alınır. Hız sınırlamasını işleyen `retryOn429` seçeneğinden ayrıdır; akış ortası hataları hiçbir zaman yeniden oynatılmaz. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Yalnızca yerel `openai-responses` sağlayıcıları, `authMode: "forward"` dahil. Çağıranın hiçbir şey gözlemlemediği bir anda başarısız olan gönderimin isteğe bağlı olarak değiştirilmesi: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İki belirsiz aşamayı da kapsar: yanıt başlığı gelmeden kopan bağlantı ve başlıktan sonra yalnızca denetim olayları taşırken kopan SSE gövdesi. Yalnızca kendi kendine yeten bir istek değiştirilir: `store: false`, eksiksiz `input`, `previous_response_id`, `conversation` veya `stream_id` bulunmaması ve yalnızca istemcinin yürüttüğü araçlar. `replacements`, BİR mantıksal isteğin tüm bacaklar ve tüm combo alt istekleri boyunca yapabileceği değiştirme gönderimi sayısıdır (1..2, varsayılan 1). Bacak başına yeniden deneme sayısı da gönderim bütçesi de değildir; bu yüzden bir değiştirme gönderimi, ilgili bacağın hâlihazırda sahip olduğu gönderim payına sığmak zorundadır. Halihazırda çıktı veya araç çağrısı üretmiş bir istek, bu değer ne olursa olsun asla değiştirilmez. Kaynak ilk çıkarımı zaten başlatmışsa değiştirilen çıkarım yine ücretlendirilebilir; bu nedenle varsayılan olarak kapalıdır. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`'u yalnızca `auto` veya `none` kabul eden modeller; zorunlu seçimlerin derecesi düşürülür. | | `preserveReasoningContentModels?` | `string[]` | Sohbet geçmişinde önceki asistan `reasoning_content`'ini gerektiren modeller. | | `reasoningDetailsModels?` | `string[]` | Thinking'i yapılandırılmış bir `reasoning_details` dizisi olarak döndüren modeller (`reasoning_split` ile MiniMax M-serisi); akış deltaları önek farkıyla işlenen kümülatif anlık görüntülerdir ve korunan reasoning, `reasoning_content` dizesi yerine `reasoning_details` dizisi olarak yeniden oynatılır. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 90ab7966d17..5e748183d92 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -131,6 +131,7 @@ selector,而不是分配一个新名称。 | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。可选的同目标 429 重试:未配置 `retryOn429` 时功能关闭;对象存在即启用,除非 `enabled: false`。收到 429 时等待(上游 `Retry-After` 或固定间隔)后在相同 key 上重放完全相同请求,再进入任何 key 故障转移——覆盖主文本恢复循环、Responses passthrough、图像/视频桥、web-search 侧车与终结续接。重放仅适用于流开始前的 HTTP 429 响应;自定义 `runTurn` 传输不在 HTTP 重试循环范围内。`attempts` 是首个 429 之后的同 key 重放次数(总发送次数 = `attempts` + 1),是主恢复循环、终结守卫续接与桥接重试共享的按请求统一预算;`attempts` 耗尽只会停止进一步的同 key 重放:随后按可用目标进行正常的 key 故障转移或最终错误处理——key 认证的 passthrough 线路上没有故障转移,因此耗尽的 429 会原样透出。Codex 自身从不重试 429,因此这是单 key 提供商唯一的防线。默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待以 `maxIntervalMs` 为上限,其本身上限 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 仅限使用 key 认证的 `openai-chat` 与 `openai-responses` 提供商。`authMode: "forward"` 的提供商(ChatGPT 账号池)从不读取此选项,保持默认重试次数。可选的流开始前上游瞬态状态码(500、502、503、504、520、521、522)重试:未配置时关闭;对象存在即启用,除非 `enabled: false`。覆盖初始 Responses 请求、终结守卫续接、原生 `/v1/chat/completions`,以及 429/账户恢复重新获取。`attempts` 是单个请求允许向上游发送的总次数,包含首次发送(1..10,默认 3);它是与连接重置恢复共享的按请求预算,因此 `3` 表示最多只有三个实际请求到达提供商。等待采用固定 400 毫秒的指数退避,上限为 5 秒,并遵循 `Retry-After`。此选项独立于处理速率限制的 `retryOn429`;流开始后的故障绝不会重放。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 仅限原生 `openai-responses` 提供商,包含 `authMode: "forward"`。可选地替换一次在调用方尚未观察到任何内容时就失败的发送:未配置时关闭;对象存在即启用,除非 `enabled: false`。涵盖两个不确定阶段——响应头到达前连接断开,以及响应头之后 SSE 正文只承载控制事件时断开。只有自包含的请求才会被替换:`store: false`、完整的 `input`、没有 `previous_response_id`/`conversation`/`stream_id`,且只使用由客户端执行的工具。`replacements` 是单个逻辑请求在所有环节和所有组合子请求中可以进行的替换发送次数(1..2,默认 1);它既不是按环节的重试次数,也不是发送预算,因此替换发送仍必须落在该环节已有的发送额度之内。已经产生输出或工具调用的请求,无论此值为何都不会被替换。如果上游已经开始了第一次推理,被替换的推理仍可能计费,因此该选项默认关闭。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` 只接受 `auto` 或 `none` 的模型;强制选择会被降级。 | | `preserveReasoningContentModels?` | `string[]` | 需要在聊天历史中保留先前 assistant `reasoning_content` 的模型。 | | `reasoningDetailsModels?` | `string[]` | 以结构化 `reasoning_details` 数组返回思考内容的模型(启用 `reasoning_split` 的 MiniMax M 系列);流式增量为累积快照,按前缀差分处理,保留的推理以 `reasoning_details` 数组而非 `reasoning_content` 字符串回放。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index d90eea6e7c6..c10abd0d869 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -103,6 +103,7 @@ ocx models provider openrouter on | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 僅限原生 `openai-responses` 供應商,包含 `authMode: "forward"`。可選擇性地替換一次在呼叫端尚未觀察到任何內容時就失敗的傳送:未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋兩個不確定階段——回應標頭抵達前連線中斷,以及標頭之後 SSE 內文只載有控制事件時中斷。只有自我完備的請求才會被替換:`store: false`、完整的 `input`、沒有 `previous_response_id`/`conversation`/`stream_id`,且僅使用由用戶端執行的工具。`replacements` 是單一邏輯請求在所有環節與所有組合子請求中可進行的替換傳送次數(1..2,預設 1);它既不是各環節的重試次數,也不是傳送預算,因此替換傳送仍必須落在該環節既有的傳送額度之內。已經產生輸出或工具呼叫的請求,無論此值為何都不會被替換。若上游已經開始第一次推論,被替換的推論仍可能計費,因此此選項預設停用。 | | `autoToolChoiceOnlyModels?` | `string[]` | 其 `tool_choice` 僅接受 `auto` 或 `none` 的模型;強制選擇被降級。 | | `preserveReasoningContentModels?` | `string[]` | 需要在 chat 歷史中保留先前 assistant `reasoning_content` 的模型。 | | `reasoningDetailsModels?` | `string[]` | 以結構化 `reasoning_details` 陣列回傳思考內容的模型(啟用 `reasoning_split` 的 MiniMax M 系列);串流增量為累積快照,以前綴差分處理,保留的推理以 `reasoning_details` 陣列而非 `reasoning_content` 字串重播。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a3d94f7c734..279dcc3dc35 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1613,7 +1613,10 @@ "claude-intercept-settings.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", "claude-desktop-mode-explanation.test.ts": "claude-integration", - "claude-intercept-integration.test.ts": "server" + "claude-intercept-integration.test.ts": "server", + "management-provider-reset-replay.test.ts": "server", + "responses-reset-replay.test.ts": "responses", + "ambiguous-resend-gate.test.ts": "lib" }, "migrated": [ "adapters", diff --git a/src/config.ts b/src/config.ts index 9788f252144..0c63a168bad 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,7 +110,7 @@ export { sanitizeModelCostsForDisplay, modelPreferHostedToolsConfigError, } from "./config/schema/leaf-validators"; -export { hardenExistingSecret, retryOn429PolicyConfigError } from "./config/load-degrade"; +export { hardenExistingSecret, retryOn429PolicyConfigError, retryOnResetPolicyConfigError } from "./config/load-degrade"; export { backupInvalidConfig } from "./config/salvage"; export type { ConfigDiagnostics, ConfigAdmissionSnapshot } from "./config/diagnostics"; export { diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 1787ad109a6..daf16aba298 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -32,6 +32,7 @@ import { quotaResetNotifySchema, remoteGuiConfigSchema, retryOn429PolicySchema, + retryOnResetPolicySchema, runtimeRoleSchema, spendSchema, } from "./schema/leaf-validators"; @@ -195,18 +196,44 @@ export function sanitizeRetryOn429ForLoad(parsed: unknown): void { * redacted (a malformed write can place a secret in a property name). */ export function retryOn429PolicyConfigError(policy: unknown): string | null { + return strictPolicyConfigError("retryOn429", retryOn429PolicySchema, policy); +} + +/** + * Management write-boundary validation for `retryOnReset`, with the same fail-closed contract + * as `retryOn429PolicyConfigError`: the load-time schema degrades a malformed block to + * "absent", so this is the one place a bad value is refused instead of silently dropped. + */ +export function retryOnResetPolicyConfigError(policy: unknown): string | null { + return strictPolicyConfigError("retryOnReset", retryOnResetPolicySchema, policy); +} + +/** + * The shared body of both. Written once because the two differ only in the field name they + * report, and a second hand-copied formatter is a second place for the redaction to be + * forgotten. + */ +function strictPolicyConfigError( + field: string, + schema: { + safeParse: (value: unknown) => { success: true } | { + success: false; + error: { issues: Array<{ code: string; message: string; path: PropertyKey[]; keys?: string[] }> }; + }; + }, + policy: unknown, +): string | null { if (policy === undefined) return null; - const result = retryOn429PolicySchema.safeParse(policy); + const result = schema.safeParse(policy); if (result.success) return null; const first = result.error.issues[0]; - if (!first) return "retryOn429 is invalid"; - if (first.code === "unrecognized_keys") { + if (!first) return `${field} is invalid`; + if (first.code === "unrecognized_keys" && first.keys) { const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", "); - return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`; + return `${field} has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`; } - if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`; - const field = String(first.path[first.path.length - 1]); - return `retryOn429.${field} is invalid (${first.message})`; + if (first.path.length === 0) return `${field} is invalid (${first.message})`; + return `${field}.${String(first.path[first.path.length - 1])} is invalid (${first.message})`; } export function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void { diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 0906b75671e..0210a9d0c3d 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -81,6 +81,17 @@ const transientRetryOn5xxPolicySchema = z.object({ attempts: z.number().int().min(1).max(10).optional(), }).strict(); +/** + * `retryOnReset` accepts only these keys. `replacements` counts DUPLICATE inferences the + * operator is willing to risk for one logical request, so the ceiling is two rather than a + * send budget: this is the one send the proxy otherwise refuses outright, and a third of them + * says the connection, not the retry policy, is the problem. + */ +export const retryOnResetPolicySchema = z.object({ + enabled: z.boolean().optional(), + replacements: z.number().int().min(1).max(2).optional(), +}).strict(); + const requestPacingRuleSchema = z.object({ // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs. requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), @@ -331,6 +342,10 @@ export const providerConfigSchema = z.object({ .optional(), retryOn429: retryOn429PolicySchema.optional(), transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(), + // Degrades to "absent" like `webSearchBridge`: a malformed hand edit of an opt-in feature + // that is off by default must not send the operator through invalid-config recovery. The + // management write boundary still rejects it loudly (`retryOnResetPolicyConfigError`). + retryOnReset: retryOnResetPolicySchema.optional().catch(undefined), codexAccountMode: z.enum(["pool", "direct"]).optional(), // Validated rather than passed through: this schema ends in `.passthrough()`, so an // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be diff --git a/src/lib/request-execution-budget.ts b/src/lib/request-execution-budget.ts index f17e44350fe..f6cd5042366 100644 --- a/src/lib/request-execution-budget.ts +++ b/src/lib/request-execution-budget.ts @@ -165,6 +165,21 @@ export interface RequestExecutionBudget extends TransientSendBudget { readonly alternateTargetSends: number; readonly targetTransitions: number; readonly lastTargetKey: string | undefined; + /** + * Spend one operator-granted replacement for an AMBIGUOUS failure of this logical request, + * up to `limit`. False once the request has none left. + * + * It lives on the budget rather than beside the policy that grants it because it has to be + * shared exactly where the physical-send ledger is shared. A combo child derives its own + * budget from the parent's ledger, and two counters would let a request whose parent leg + * reset before the head and whose child leg reset after it replace an unknown-state send + * twice. It is NOT a send budget: an authorised replacement still has to fit inside + * `remainingBaseSends` like every other send. + * + * Optional so a hand-written stub that satisfies the shape test keeps typechecking; a caller + * that cannot reach it has no operator override, which is the fail-closed answer. + */ + claimAmbiguousResend?(limit: number): boolean; } const RESERVE_FUNDED_CLASSES: ReadonlySet = new Set([ @@ -192,6 +207,12 @@ let logicalRequestSeq = 0; interface SharedSendLedger { spent: number; pendingExternalSends: number; + /** + * Replacements this logical request has already spent on ambiguous failures. Beside `spent` + * for the same reason `pendingExternalSends` is: a derived scope that shared one without the + * other would hand the request a second grant. + */ + ambiguousResendsClaimed: number; readonly observer?: RequestSendObserver; } @@ -239,6 +260,12 @@ function createRequestExecutionBudgetWithLedger( const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0; return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent)); }, + claimAmbiguousResend(limit: number): boolean { + const ceiling = Number.isFinite(limit) ? Math.trunc(limit) : 0; + if (counter.ambiguousResendsClaimed >= ceiling) return false; + counter.ambiguousResendsClaimed += 1; + return true; + }, reserveDispatch(intent: DispatchIntent): DispatchDecision { if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" }; if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" }; @@ -336,6 +363,7 @@ export function createRequestExecutionBudget( return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, { spent: 0, pendingExternalSends: 0, + ambiguousResendsClaimed: 0, ...(observer ? { observer } : {}), }); } @@ -370,11 +398,14 @@ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger { const existing = sharedSendLedgers.get(parent); if (existing) return existing; let pendingExternalSends = 0; + let ambiguousResendsClaimed = 0; return { get spent(): number { return parent.used; }, set spent(next: number) { parent.used = next; }, get pendingExternalSends(): number { return pendingExternalSends; }, set pendingExternalSends(next: number) { pendingExternalSends = next; }, + get ambiguousResendsClaimed(): number { return ambiguousResendsClaimed; }, + set ambiguousResendsClaimed(next: number) { ambiguousResendsClaimed = next; }, }; } diff --git a/src/lib/request-resend-gate.ts b/src/lib/request-resend-gate.ts new file mode 100644 index 00000000000..0107aa648bb --- /dev/null +++ b/src/lib/request-resend-gate.ts @@ -0,0 +1,138 @@ +/** + * Whether one leg of a logical request may send it again, asked in the #5266 vocabulary. + * + * Two pull requests arrived at this question from opposite sides of the response head. #4942 + * asked it for a connection that died before any head; #4989 asked it for an SSE body that + * died after the head while carrying only control events. Both are the same row of the stage + * table: a stage the caller observed nothing at, with a cause that cannot prove the origin did + * not run the turn. `resendPermission` answers `refused-ambiguous` for both, and + * request-failure-model.ts already names the only thing that may override that answer -- a + * narrowly scoped recovery a maintainer opted into and bounded. + * + * One override, not two. The reason this module exists rather than a boolean in each caller is + * that a request which resets before the head and again after it would otherwise buy a + * replacement send on each side, and the second one is exactly the duplicated inference the + * refusal exists to prevent. The allowance is claimed HERE, at the moment of authorisation, so + * a caller cannot ask without paying. + * + * MUST stay a leaf. It imports the vocabulary as values and everything else as types, so it + * reaches no request path that did not already have it. + */ +import { + causeForRecoveryKind, + permitsResend, + resendPermission, + resendSendClass, + type RequestFailureCause, + type RequestFailureStage, + type ResendPermission, +} from "./request-failure-model"; +import type { SendClass } from "./request-execution-budget"; +import type { AttemptRecoveryKind } from "../usage/telemetry-contract"; + +/** + * Why an authorisation was refused. + * + * The three ambiguous members are separate because they need different operator responses: no + * policy is a configuration choice, a request the proxy cannot judge is a property of the turn, + * and a spent allowance means the replacement already went somewhere else in this request. + */ +export const RESEND_REFUSALS = Object.freeze([ + /** The caller already observed output, an effect, or the delivered answer. */ + "committed", + /** Identical bytes would get the identical answer. */ + "futile", + /** The origin's execution state is unknown and no operator policy overrides that. */ + "ambiguous-no-policy", + /** An operator policy exists, but this request's second send could do more than re-infer. */ + "ambiguous-request-not-replayable", + /** The operator policy exists and its replacement was already spent by this request. */ + "ambiguous-allowance-spent", +] as const); + +export type ResendRefusal = typeof RESEND_REFUSALS[number]; + +/** + * The operator-granted replacement for ONE logical request. + * + * `claim` is the single counter both stages draw on. It is a method rather than a number + * because the holder is the request's send ledger, which a combo child shares with its parent; + * a number passed down per leg is what let each leg hold its own. + */ +export interface AmbiguousResendAllowance { + /** True when a second send of this request's body can only repeat the inference. */ + readonly selfContained: boolean; + /** Spend one replacement. False once the request has none left. */ + claim(): boolean; +} + +interface ResendDecisionBase { + readonly stage: RequestFailureStage; + readonly cause: RequestFailureCause; + readonly permission: ResendPermission; + /** + * The recovery this send will be recorded as, when the caller asked in those terms. Carried + * back rather than re-chosen at the call site: the cause was derived from it, so recording a + * different kind would describe the send by a reason the gate never evaluated. + */ + readonly recoveryKind?: AttemptRecoveryKind; +} + +export type ResendDecision = + | ResendDecisionBase & { + readonly allowed: true; + /** Which request-wide send class funds it, or null when the cause funds no resend. */ + readonly sendClass: SendClass | null; + /** True when the table refused and an operator allowance was spent to proceed. */ + readonly spentOperatorAllowance: boolean; + } + | ResendDecisionBase & { readonly allowed: false; readonly refusal: ResendRefusal }; + +/** + * Decide whether this proxy may send the request again after a failure at `stage` caused by + * `cause`, spending `allowance` when the table refuses only because the upstream state is + * unknown. + * + * The allowance is touched on exactly one path: a decision the table would otherwise refuse as + * ambiguous, for a request whose body the caller has judged replayable. A committed or futile + * failure never reaches it, so a turn that already produced output cannot quietly drain the + * replacement a later ambiguous reset would have been entitled to. + */ +export function authorizeResend( + stage: RequestFailureStage, + cause: RequestFailureCause, + allowance?: AmbiguousResendAllowance, + recoveryKind?: AttemptRecoveryKind, +): ResendDecision { + const permission = resendPermission(stage, cause); + const base = { stage, cause, permission, ...(recoveryKind ? { recoveryKind } : {}) }; + if (permitsResend(permission)) { + return { ...base, allowed: true, sendClass: resendSendClass(cause), spentOperatorAllowance: false }; + } + if (permission === "refused-committed") return { ...base, allowed: false, refusal: "committed" }; + if (permission === "refused-futile") return { ...base, allowed: false, refusal: "futile" }; + if (!allowance) return { ...base, allowed: false, refusal: "ambiguous-no-policy" }; + if (!allowance.selfContained) { + return { ...base, allowed: false, refusal: "ambiguous-request-not-replayable" }; + } + // Claimed last, and only here. Asking earlier would spend the request's one replacement on a + // question whose answer was already no. + if (!allowance.claim()) return { ...base, allowed: false, refusal: "ambiguous-allowance-spent" }; + return { ...base, allowed: true, sendClass: resendSendClass(cause), spentOperatorAllowance: true }; +} + +/** + * The same decision, asked in terms of the recovery this proxy will RECORD for the send. + * + * Deriving the cause from the recorded kind is what keeps the log honest: the reason an + * operator reads beside a send count is the reason the gate weighed, because it is the same + * value. A call site that recorded one kind and reasoned about another is how a send count + * stops meaning anything. + */ +export function authorizeResendForRecovery( + stage: RequestFailureStage, + kind: AttemptRecoveryKind, + allowance?: AmbiguousResendAllowance, +): ResendDecision { + return authorizeResend(stage, causeForRecoveryKind(kind), allowance, kind); +} diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 8dd6f1146f1..30f17a7b5d8 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -8,13 +8,15 @@ * becomes a terminal, non-replayable response unless the operation is explicitly safe. * * Deliberately narrow: timeouts, aborts, ECONNREFUSED/DNS/TLS failures, and HTTP error - * statuses (returned as Response, never thrown) are NOT retried. Mid-stream SSE resets are - * out of scope — the response has already resolved by then. + * statuses (returned as Response, never thrown) are NOT retried. A reset after the response + * head is out of scope here, because the response has already resolved by then; the Responses + * transport asks the same question at that stage through the shared resend gate. * * MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports * the shared abort helpers from here). */ import { clearableDeadline } from "./abort"; +import { redactSecretString } from "./redact"; /** * Responses the origin may already be executing. RFC 9110 §9.2.2 forbids an intermediary @@ -421,6 +423,20 @@ export interface ResetRetryOptions { * uncounted but UNCOUNTABLE: the callback existed on a type those call sites never reach. */ onSendsConsumed?: (sends: number) => void; + /** + * Spend one operator-granted replacement for a pre-header reset this helper would otherwise + * refuse. Absent means no operator policy, which is the fail-closed answer. + * + * A callback rather than a count, and the difference is the whole point. A count handed to + * each leg of a request is a count each leg holds: a rotation leg, a refresh leg and a + * same-target 429 leg carry the same turn, so three numbers is three replacements of one + * possibly-executed inference. The callback draws on ONE allowance held by the logical + * request, which the post-header protocol gate draws on too. + * + * It never widens the send budget. A claimed replacement still has to fit inside + * `attempts`, exactly like every other send this leg makes. + */ + claimAmbiguousResend?: () => boolean; } export interface TransientRetryOptions extends ResetRetryOptions { @@ -494,6 +510,23 @@ export function applyUpstreamRecoveryInit( return { ...init, headers, keepalive: false }; } +/** + * The refusal this proxy returns for an ambiguous reset it will not replace. The WeakSet + * markers protect in-process recovery and the code survives JSON re-wrapping, so a combo or + * account-recovery layer downstream cannot read it as a replayable upstream fault. The raw + * exception is never exposed: it can carry credentials or request data. + */ +export function replayRefusalResponse(): Response { + const response = new Response(JSON.stringify({ error: { + type: "upstream_error", + code: UPSTREAM_RESET_REPLAY_REFUSED_CODE, + message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.", + } }), { status: REPLAY_REFUSED_STATUS, headers: { "content-type": "application/json" } }); + markResponseNonReplayable(response); + markReplayRefusalResponse(response); + return response; +} + /** * Run `doFetch` within one send budget. Connection-reset-shaped rejections are * terminal by default; only an explicitly replay-safe operation receives reset retries @@ -511,6 +544,10 @@ export async function fetchWithResetRetry( if (attempts === 0) throw new SendBudgetExhaustedError(opts.label); let lastError: unknown; let sawReset = false; + // True once this leg has spent the request's operator allowance. From that point the leg can + // only settle as the refusal: a second send of a possibly-executed turn is already out, and + // handing the client anything it would retry compounds it. + let spentOperatorReplacement = false; for (let attempt = 0; attempt < attempts; attempt++) { if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal); // Reported before the await, one physical send at a time: a send that rejects has still @@ -522,31 +559,37 @@ export async function fetchWithResetRetry( } catch (err) { if (opts.abortSignal?.aborted) throw err; if (!isConnectionResetError(err)) { + // Whatever ended the leg, an operator replacement already went out, so the first send + // may have run the turn. Settle it as the refusal instead of throwing into a caller + // whose transport-failure path answers with a client-retryable 502. + if (spentOperatorReplacement) return replayRefusalResponse(); // A reset that already reached the origin is credential-visible // evidence: keep it attached so the terminal rejection cannot be // downgraded to the pre-connection neutral class (#914 review). if (sawReset) throw new UpstreamRetryEvidenceError([], err, true); throw err; } - if (opts.replaySafe !== true) { - // Return evidence instead of throwing a generic transport error: outer catches + if (opts.replaySafe === true) { + // Repeating this operation cannot duplicate anything, so an exhausted budget rethrows + // and the caller's own error path takes over. + if (attempt === attempts - 1) throw err; + } else { + // The stage table refuses an ambiguous pre-header reset. The only thing that overrides + // it is an operator allowance, and claiming it here is what keeps the grant single -- + // the post-header protocol gate spends the same counter for the same logical request. + // Return evidence rather than throwing a generic transport error: outer catches // otherwise turn it into a replayable 502 and a combo/account recovery resends it. - // The WeakSet protects in-process recovery; the code survives JSON re-wrapping. - // Never expose the raw exception, which can contain credentials or request data. - const response = new Response(JSON.stringify({ error: { - type: "upstream_error", - code: UPSTREAM_RESET_REPLAY_REFUSED_CODE, - message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.", - } }), { status: REPLAY_REFUSED_STATUS, headers: { "content-type": "application/json" } }); - markResponseNonReplayable(response); - markReplayRefusalResponse(response); - return response; + if (attempt + 1 >= attempts || opts.claimAmbiguousResend?.() !== true) { + return replayRefusalResponse(); + } + spentOperatorReplacement = true; } - if (attempt === attempts - 1) throw err; sawReset = true; lastError = err; console.warn( - `[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`, + `[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — ${ + spentOperatorReplacement ? "replacing" : "retrying" + } (${attempt + 2}/${attempts})`, ); await sleepWithAbort(retryBackoffDelayMs(attempt, { baseDelayMs: RESET_RETRY_BASE_DELAY_MS, @@ -661,3 +704,61 @@ export async function fetchWithTransientRetry( opts.onSendsConsumed?.(sent); } } + +export type ProtocolSafeRefetch = (signal?: AbortSignal) => Promise; + +export interface ProtocolSafeRefetchOptions extends ResetRetryOptions { + /** The replacement must match the response contract already selected for the client. */ + acceptResponse?: (response: Response) => boolean; + /** + * Spend the logical request's allowance, immediately before the replacement send. + * + * Asked here and not earlier so a failure this helper would refuse on its own terms -- a + * non-reset error, a cancelled caller, a spent send budget -- cannot drain the one + * replacement a later ambiguous reset was entitled to. False refuses the replacement. + */ + authorize?: () => boolean; +} + +/** + * Attempt ONE caller-authorized replacement of a stream that died after the response head. + * + * The caller owns the proof that nothing was observed -- it comes from protocol inspection, + * not from this module -- and owns the physical-send budget. What lives here is the part that + * is easy to get wrong: a replacement is only usable if it is a fresh, unlocked, unread body + * that matches the contract already promised to the client, and anything else has to be + * cancelled and the original failure preserved. + */ +export async function refetchAfterProtocolSafeReset( + doFetch: ProtocolSafeRefetch, + err: unknown, + opts: ProtocolSafeRefetchOptions = {}, +): Promise { + if (!isConnectionResetError(err) || opts.abortSignal?.aborted || opts.attempts === 0) return null; + const label = opts.label + ? " (" + redactSecretString(opts.label).replace(/[\r\n\u0000-\u001f\u007f]/g, "").slice(0, 128) + ")" + : ""; + if (opts.authorize && !opts.authorize()) { + console.warn("[upstream-retry] post-header reset replacement refused" + label + "; preserving original stream error"); + return null; + } + let replacement: Response; + try { + replacement = await doFetch(opts.abortSignal); + } catch { + console.warn("[upstream-retry] protocol-safe refetch failed" + label + "; preserving original stream error"); + return null; + } + const body = replacement.body; + let accepted = !opts.abortSignal?.aborted && replacement.ok && body !== null + && !replacement.bodyUsed && !body.locked && !isNonReplayableResponse(replacement); + try { if (accepted && opts.acceptResponse) accepted = opts.acceptResponse(replacement); } + catch { accepted = false; } + if (!accepted || opts.abortSignal?.aborted || body?.locked) { + try { void body?.cancel().catch(() => {}); } catch { /* already locked or closed */ } + console.warn("[upstream-retry] protocol-safe refetch rejected" + label + "; preserving original stream error"); + return null; + } + console.warn("[upstream-retry] pre-output Responses reset" + label + "; using one replacement stream"); + return replacement; +} diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index e4552df141a..c96b555029b 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -13,7 +13,7 @@ import type { ProviderApiKeySelection } from "../types/provider"; import { routedProviderConfig } from "../router"; import { getProviderRegistryEntry } from "./registry"; import { normalizedBaseUrl } from "./quota/vendor-probes-key"; -import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types"; +import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, ResetReplayPolicy, TransientRetryPolicy } from "../types"; import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport"; import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; @@ -326,6 +326,16 @@ const DEFAULT_TRANSIENT_RETRY = { attempts: 3, } as const satisfies Required; +/** + * Default used when a provider opts in with a bare `retryOnReset: {}`: one replacement for + * the whole logical request. `replacements` counts duplicate inferences the operator accepts, + * not retries and not sends. + */ +const DEFAULT_RESET_REPLAY = { + enabled: true, + replacements: 1, +} as const satisfies Required; + /** Map<`${providerName}\0${keyId}`, KeyCooldown> */ const keyCooldowns = new Map(); @@ -616,6 +626,26 @@ export function transientRetryPolicyFor( }; } +/** + * Normalize a provider's `retryOnReset` policy, or return null when it is absent or explicitly + * disabled. + * + * No auth-mode gate: the canonical ChatGPT backend is `forward` auth and is the send this + * policy exists for. Whether a given REQUEST may be replaced is a per-body decision made in + * src/server/responses/reset-replay.ts, and how many replacements the request gets is the + * shared allowance on its execution budget. This function only reads the operator's intent. + */ +export function resetReplayPolicyFor( + provider: Pick, +): Required | null { + const policy = provider.retryOnReset; + if (!policy || policy.enabled === false) return null; + return { + enabled: policy.enabled ?? DEFAULT_RESET_REPLAY.enabled, + replacements: policy.replacements ?? DEFAULT_RESET_REPLAY.replacements, + }; +} + /** * Wait before the next same-target replay: upstream Retry-After (seconds or HTTP-date) when * `respectRetryAfter` is on and the header parses, capped at `maxIntervalMs`; otherwise the diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index ad987bf4e5a..a0ef077dd5d 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -11,6 +11,7 @@ import { providerWebSearchBridgeConfigError, requestPacingConfigError, retryOn429PolicyConfigError, + retryOnResetPolicyConfigError, sanitizeModelCostsForDisplay, } from "../config"; import { @@ -723,6 +724,10 @@ export function providerManagementConfigError( delete canonicalCandidate.modelCosts; // requestPacing is a user-owned transport overlay, not part of the canonical seed. delete canonicalCandidate.requestPacing; + // retryOnReset is the same kind of overlay: it tunes how this provider's own Responses + // sends recover, not what the canonical forward seed is. Validated below + // (retryOnResetPolicyConfigError). + delete canonicalCandidate.retryOnReset; // Context windows are the same kind of user-owned overlay as requestPacing: the operator // narrowing what their own native rows advertise. They can only ever LOWER the measured // window (see nativeOpenAiContextWindow), so admitting them cannot widen what the proxy @@ -769,6 +774,10 @@ export function providerManagementConfigError( // it before it reaches the management API response. return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`; } + const retryOnResetError = retryOnResetPolicyConfigError(raw.retryOnReset); + if (retryOnResetError) { + return `provider ${JSON.stringify(redactSecretString(name))} ${retryOnResetError}`; + } const requestPacingError = requestPacingConfigError(raw.requestPacing); if (requestPacingError) { return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`; @@ -1049,6 +1058,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { showThinkingSummary: "editor", retryOn429: "editor", transientRetryOn5xx: "editor", + retryOnReset: "editor", reasoningSplitModels: "editor", reasoningDetailsModels: "editor", thinkingToggleModels: "editor", diff --git a/src/server/relay.ts b/src/server/relay.ts index 7e84092fb62..4e2f8bbd67a 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -989,6 +989,14 @@ export type SseInspectorHandlers = { * with an empty `output`. */ onParsedPayload?: (payload: unknown) => void; + /** + * A complete data payload that did not parse as a JSON event, `[DONE]` included. + * + * An inspector that only hears about parsed events cannot tell "nothing has been emitted" + * from "something was emitted that I could not read", and a replay decision needs that + * difference: an unreadable payload is a payload the caller may already have seen. + */ + onOpaquePayload?: () => void; onFirstOutput?: () => void; /** * Provider-scoped compatibility: persist the completed snapshot under the @@ -1177,6 +1185,11 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector if (handlers.onParsedPayload && parsed !== undefined) { try { handlers.onParsedPayload(parsed); } catch { /* inspection must never throw into the pump */ } } + // The other half of the same observation. A payload that did not parse still reached the + // caller, so a consumer deciding whether anything has been emitted has to hear about it. + if (handlers.onOpaquePayload && parsed === undefined) { + try { handlers.onOpaquePayload(); } catch { /* inspection must never throw into the pump */ } + } reportFirstOutput.parsed(parsed); const status = terminalStatusFromParsed(parsed); const policyTerminal = status === "failed" diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 22e1ae12e45..801946fb571 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -1,6 +1,7 @@ import type { ResponsesTerminalStatus } from "../../bridge"; import { comboFailureDecision } from "../../combos"; import { httpStatusFromTerminalError } from "../../lib/errors"; +import type { RequestFailureStage } from "../../lib/request-failure-model"; import type { RequestLogContext } from "../request-log"; import { createSseInspector } from "../relay"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; @@ -108,9 +109,48 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true; const type = (payload as { type?: unknown }).type; if (typeof type !== "string") return true; + if (type === "response.created") { + // A created event is a control frame only while its snapshot is empty. An origin that + // resumes a turn can put completed items in it, and treating that as a prelude would let + // a replacement re-emit output the caller already received. + const response = (payload as { response?: unknown }).response; + if (response && typeof response === "object" && !Array.isArray(response)) { + const output = (response as { output?: unknown }).output; + if (Array.isArray(output) && output.length > 0) return true; + } + } return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type); } +/** + * How far this SSE body got, in the vocabulary of src/lib/request-failure-model.ts. + * + * The preflight cannot separate `semantic-output` from `side-effect`: it classifies any event + * that is not a lifecycle control frame as committing, without reading item types. Both stages + * refuse a resend, so the distinction would change no decision -- it is named here so a later + * reader does not mistake the collapse for an omission. + * + * A terminal that settled carrying no output is `protocol-prelude`, not `terminal`. That is + * the failure model's own rule: `terminal` means the answer was delivered, and an empty + * completion delivered none. + * + * Only the two nothing-observed stages actually reach a read error today: the loop below hands + * the body back as `accepted` the moment output commits or a terminal arrives, so a stream + * that committed anything never reports a stage at all. The committed branches stay because + * this has to be total for any other caller, and because a later change to that loop must not + * be able to promote a committed stream into a replaceable one by omission. + */ +function observedResponsesStage(state: { + readonly outputCommitted: boolean; + readonly terminalStatus: ResponsesTerminalStatus | undefined; + readonly responseCreated: boolean; +}): RequestFailureStage { + if (state.outputCommitted) return "semantic-output"; + if (state.terminalStatus === "completed") return "terminal"; + if (state.responseCreated || state.terminalStatus !== undefined) return "protocol-prelude"; + return "headers-only"; +} + function replayBufferedResponse( response: Response, reader: ReadableStreamDefaultReader, @@ -186,13 +226,22 @@ function failedTerminalResponse( export type ComboStreamPreflightResult = | { kind: "accepted"; response: Response } - | { kind: "failed"; response: Response }; + | { kind: "failed"; response: Response } + /** + * The body errored mid-stream and `replayReadErrors` asked for the prefix back rather than + * a rethrow. `stage` is how far the inspection actually got; whether that permits a + * replacement is the resend gate's decision, not this function's. Callers that only act on + * a projected terminal can treat this exactly as `accepted`, which is what it was before + * the stage became observable. + */ + | { kind: "read-error"; response: Response; error: unknown; stage: RequestFailureStage }; /** - * Buffer a combo child's downstream SSE only until the request becomes unsafe to - * replay or reaches a terminal. This owns exactly one body reader. The aggregate - * buffer is capped by bytes and retained chunks; hitting either cap commits the - * current target instead of growing memory or guessing that replay is safe. + * Buffer a Responses SSE only until the request becomes unsafe to replay or reaches a + * terminal. Combo failover and native post-header reset recovery share this protocol + * boundary, because they are asking the same question about the same bytes. This owns exactly + * one body reader. The aggregate buffer is capped by bytes and retained chunks; hitting either + * cap commits the current target instead of growing memory or guessing that replay is safe. */ export async function preflightComboStreamResponse( response: Response, @@ -211,12 +260,18 @@ export async function preflightComboStreamResponse( const buffered: Uint8Array[] = []; let bufferedBytes = 0; let outputCommitted = false; + let responseCreated = false; let terminalStatus: ResponsesTerminalStatus | undefined; let retryableTerminalPayload: Record | undefined; const inspector = createSseInspector({ logCtx, + // A payload the inspector could not parse still reached this proxy, and it may be output. + // Committing on it is what keeps an unreadable frame from reading as an empty prelude. + onOpaquePayload: () => { outputCommitted = true; }, onParsedPayload: payload => { if (terminalStatus !== undefined || outputCommitted || retryableTerminalPayload) return; + if (payload !== null && typeof payload === "object" && !Array.isArray(payload) + && (payload as { type?: unknown }).type === "response.created") responseCreated = true; const retryable = retryableTerminal(payload); const matchedBareError = retryable && payload !== null && typeof payload === "object" && !Array.isArray(payload) && (payload as { type?: unknown }).type === "error"; @@ -239,7 +294,9 @@ export async function preflightComboStreamResponse( // The native relay still owns post-header transport failures. Preserve // the bounded prefix and the errored reader; cancelling it here would // erase the failure before either client relay or inspection sees it. - return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) }; + const replay = replayBufferedResponse(response, reader, buffered); + const stage = observedResponsesStage({ outputCommitted, terminalStatus, responseCreated }); + return { kind: "read-error", response: replay, error, stage }; } if (next.done) { inspector.finish(); @@ -277,3 +334,108 @@ export async function preflightComboStreamResponse( inspector.dispose(); } } + +/** Produce a replacement body for a mid-stream failure at `stage`, or null to keep the error. */ +export type ProtocolSafeResetRecovery = ( + error: unknown, + stage: RequestFailureStage, +) => Promise; + +/** + * Defer protocol inspection until the downstream actually pulls the body. + * + * Direct passthrough must return response headers before the first SSE event arrives, so the + * inspection cannot be awaited at the dispatch site the way combo routing awaits it. Wrapping + * the body moves it to the first pull, which is the earliest moment the client is willing to + * wait anyway. + */ +export function deferProtocolSafeResetRecovery( + response: Response, + logCtx: RequestLogContext, + recover: ProtocolSafeResetRecovery, + options?: { allowMissingContentType?: boolean }, +): Response { + if (!response.body) return response; + + let reader: ReadableStreamDefaultReader | undefined; + let initialization: Promise | undefined; + let closed = false; + + const cancelBody = (body: ReadableStream | null, reason?: unknown): void => { + try { void body?.cancel(reason).catch(() => {}); } catch { /* already locked or closed */ } + }; + const initialize = async (): Promise => { + const preflight = await preflightComboStreamResponse( + response, + logCtx, + () => false, + { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true }, + ); + let selected = preflight.response; + if (preflight.kind === "read-error") { + const replacement = await recover(preflight.error, preflight.stage); + if (replacement) { + cancelBody(selected.body, "using protocol-safe replacement stream"); + selected = replacement; + } + } + if (closed) { + cancelBody(selected.body, "downstream cancelled before protocol preflight completed"); + return; + } + reader = selected.body?.getReader(); + }; + + const body = new ReadableStream({ + async pull(controller) { + try { + initialization ??= initialize(); + await initialization; + if (closed) return; + if (!reader) { + closed = true; + controller.close(); + return; + } + const next = await reader.read(); + if (closed) return; + if (next.done) { + closed = true; + try { reader.releaseLock(); } catch { /* already released */ } + reader = undefined; + controller.close(); + return; + } + controller.enqueue(next.value); + } catch (error) { + if (closed) return; + closed = true; + try { reader?.releaseLock(); } catch { /* errored reader */ } + reader = undefined; + controller.error(error); + } + }, + cancel(reason) { + if (closed) return; + closed = true; + if (reader) { + try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ } + try { reader.releaseLock(); } catch { /* already released */ } + reader = undefined; + } else if (initialization === undefined) { + // Nothing has read the upstream yet, so this body is still ours to cancel. + cancelBody(response.body, reason); + } + // A cancel while the preflight is mid-flight falls through deliberately. That body is + // locked by the preflight's own reader, so cancelling it here would reject and be + // swallowed; `initialize` sees `closed` when it settles and releases whichever body it + // ended up selecting, which is the one that actually has to be let go. + }, + }, { highWaterMark: 0 }); + + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 917d74f8ad0..61981aac401 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -174,6 +174,14 @@ export function sendWithConnectionPolicy( } export interface ProviderFetchOptions { + /** + * Keep this send on HTTP even where the WebSocket upstream would normally be selected. + * + * Set by a caller replacing an HTTP stream that already failed: a WS create frame is a + * different send on a different transport, and the replacement has to be the same kind of + * exchange the client is already reading. + */ + httpOnly?: boolean; nativeControl?: NativeResponseControl; providerName?: string; modelId?: string; @@ -261,7 +269,8 @@ export function providerFetch( // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details. const unpaced = async (input: Parameters[0], init?: RequestInit) => { const upstreamWebsocket = provider.upstreamWebsocket === true; - if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { + if (!options.httpOnly && typeof input === "string" && init + && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { const egress = egressFor(input); if (providerEgressIsExplicit(egress)) { warnEgressWebsocketDowngradeOnce(providerName, describeProviderEgressForLog(egress)); diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 6676bbd1d40..7bd9b64253c 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -102,7 +102,7 @@ import { clearCodexModelDenialEvidence, recordCodexModelDenialEvidence, } from "../../codex/model-entitlements"; -import { readCodexWsStage } from "./codex-ws-wire"; +import { isCodexWsUpstreamResponse, readCodexWsStage } from "./codex-ws-wire"; import { linkAbortSignal } from "./core-lifetime"; import type { CodexAuthContext } from "../../codex/auth-context"; import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard"; @@ -112,7 +112,8 @@ import { fetchWithTransientRetry, applyUpstreamRecoveryInit, isNonReplayableResponse, - prepareSameTarget429Wait, + refetchAfterProtocolSafeReset, + prepareSameTarget429Wait, sleepWithAbort, } from "../../lib/upstream-retry"; import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error"; @@ -151,7 +152,9 @@ import { reasoningEffortRejectionText, } from "./core-opaque-recovery"; import type { RequestLogContext } from "../request-log"; -import { preflightComboStreamResponse } from "./combo-stream-preflight"; +import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight"; +import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; +import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit"; import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; @@ -189,6 +192,8 @@ export async function preparePassthroughExchange( | "genericFailovers" | "applyFailoverSnapshot" | "noteRoutedAttemptSend" + | "selectionIsCurrent" + | "requestBindings" >, responseEffects: Pick< ResponsesEffects, @@ -206,6 +211,7 @@ export async function preparePassthroughExchange( | "recoverySendAllowance" | "recoveryClassFor" | "sendBudgetExhausted" + | "claimAmbiguousResend" | "reserveCredentialHop" | "pendingHopPermit" | "workflowRootId" @@ -239,6 +245,7 @@ export async function preparePassthroughExchange( recoverySendAllowance, recoveryClassFor, sendBudgetExhausted, + claimAmbiguousResend, reserveCredentialHop, workflowRootId, } = sendBudgetState; @@ -704,6 +711,35 @@ export async function preparePassthroughExchange( ); const configuredTransientSendBudgetExhausted = (): boolean => transientSendPolicy() !== null && transientSendAttempts() === 0; + /** + * Judged once. The inbound body does not change between legs, and every rebuild this lane + * performs only ever REMOVES a hazard -- `previous_response_id` is expanded, hosted tools + * are lowered into client execution -- so a body that was replaceable stays replaceable. + * Memoized rather than recomputed because it walks the input array, and a provider that + * never opted in must not pay for it at all. + */ + let selfContainedJudgment: boolean | undefined; + const requestIsSelfContained = (): boolean => + selfContainedJudgment ??= selfContainedResponsesBody(parsed._rawBody); + /** + * The operator's replacement grant for THIS logical request. + * + * Read per leg because `route.provider` is reassigned by credential rotation and transport + * resolution inside the recovery loop, exactly like `transientSendPolicy`. The counter it + * claims from is not per leg: it lives on the request's execution budget, which a combo + * child shares, so every ambiguous stage of this request draws on the same grant. + */ + const ambiguousResend = () => + ambiguousResendAllowanceFor(route.provider, requestIsSelfContained, claimAmbiguousResend); + /** + * The pre-header row of the stage table, asked through the one gate. + * + * `fetchWithResetRetry` takes a plain callback because it is a leaf that must not import + * the server tree; routing the answer through `authorizeResendForRecovery` here is what + * keeps the decision derived from the table rather than restated as a boolean. + */ + const claimPreHeaderResend = (): boolean => + authorizeResendForRecovery("pre-header", "connection-reset", ambiguousResend()).allowed; /** * Refuse a built body that exceeds the operator's configured ceiling, before it is sent. * @@ -845,6 +881,7 @@ export async function preparePassthroughExchange( }, { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends, + claimAmbiguousResend: claimPreHeaderResend, // The OpenCode Go destination stalls-then-drops inference sends (ambiguous // pre-header resets surfacing as refused 429s); its subscription traffic is // inference-only, so a bounded reset replay here absorbs the blip instead of @@ -949,7 +986,8 @@ export async function preparePassthroughExchange( route.provider.authMode === "forward") .then(adoptObservedResponse); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts, onSendsConsumed: noteTransientSends }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts, + onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend }, ); } catch (err) { return { failed: transportFailureResponse(err) }; @@ -1030,35 +1068,54 @@ export async function preparePassthroughExchange( // every other build site; a replay is exactly when a grown payload reappears. const replayBodyRefusal = refuseOversizedOutboundBody(request); if (replayBodyRefusal) return replayBodyRefusal; - transportState.noteRoutedAttemptSend(passthroughEstimate, "oauth-401"); - upstreamResponse = await fetchWithHeaderTimeout( - request.url, - { method: request.method, headers: request.headers, body: request.body }, - upstream.signal, - connectMs, - parsed.stream, - // The replay-dispatched signal is what bounds the rest of this logical request, so it - // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing - // admission BEFORE calling the executor, so signalling at the call site would spend the - // budget even when a rejected pacing wait means nothing reaches the network. Wrapping - // the executor moves the signal to the last moment before the send, where a throw from - // here on is a genuine transport attempt. - storedPoolReplayDispatchNotifier( - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt - && responseEffects.plaintextV2AgentMessageToolNames.size === 0 - ? options.nativeControl : undefined, - dispatchOverride: oauthDispatch(request), - providerName: route.providerName, - modelId: route.modelId, - onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), - beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) - ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, - }), - codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined, - ), - route.provider.authMode === "forward", - ).then(adoptObservedResponse); + // The replay-dispatched signal is what bounds the rest of this logical request, so it + // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing + // admission BEFORE calling the executor, so signalling at the call site would spend the + // budget even when a rejected pacing wait means nothing reaches the network. Wrapping + // the executor moves the signal to the last moment before the send, where a throw from + // here on is a genuine transport attempt. The notifier is built once for the whole leg: + // it fires on the first dispatch, and a replacement is another send of the same replay + // rather than a second one to announce. + const oauthReplayExecutor = storedPoolReplayDispatchNotifier( + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt + && responseEffects.plaintextV2AgentMessageToolNames.size === 0 + ? options.nativeControl : undefined, + dispatchOverride: oauthDispatch(request), + providerName: route.providerName, + modelId: route.modelId, + onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, + }), + codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined, + ); + // Routed through the shared helper so an ambiguous reset on THIS leg answers with the + // same refusal every other leg gives. A bare fetch here rejected instead, and the + // caller's transport-failure path turns a rejection into a client-retryable 502 -- + // which invites the whole turn to be sent again, on a leg whose first send may already + // have run it. + upstreamResponse = await fetchWithTransientRetry( + recovery => { + transportState.noteRoutedAttemptSend(passthroughEstimate, recovery ?? "oauth-401"); + return fetchWithHeaderTimeout( + request.url, + applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, recovery), + upstream.signal, + connectMs, + parsed.stream, + oauthReplayExecutor, + route.provider.authMode === "forward", + ).then(adoptObservedResponse); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url), + attempts: remainingTransientSendBudget(transientSendAttempts()), + onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend }, + ); } catch (err) { return transportFailureResponse(err); } finally { @@ -1182,7 +1239,8 @@ export async function preparePassthroughExchange( route.provider.authMode === "forward") .then(adoptObservedResponse); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), + onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend }, ); } catch (err) { return transportFailureResponse(err); @@ -1314,7 +1372,8 @@ export async function preparePassthroughExchange( route.provider.authMode === "forward") .then(adoptObservedResponse); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), + onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend }, ); } catch (err) { return transportFailureResponse(err); @@ -1571,6 +1630,91 @@ export async function preparePassthroughExchange( continue passthroughRecovery; } } + + // The post-header row of the same table. A native SSE body can die after the head with the + // caller having observed nothing, which is the identical question the pre-header helper + // answers -- and the identical grant, because both claim from this request's one allowance. + const streamRecoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? ""; + const protocolRecoveryCandidate = upstreamResponse.ok + && !!upstreamResponse.body + && !isNonReplayableResponse(upstreamResponse) + // The WebSocket transport settles its own ambiguous failures and marks them + // non-replayable; re-reading its body here would be a second owner of one exchange. + && !isCodexWsUpstreamResponse(upstreamResponse) + // A downstream WebSocket turn that fell back to HTTP must relay response.created + // immediately so the client can address the turn and receive explicit control refusal. + // This preflight retains that event until output commits, so the two contracts cannot + // share one body owner. + && !(options.nativeControl && options.inboundTransport === "websocket") + && ambiguousResend() !== undefined + && remainingTransientSendBudget(transientSendAttempts()) > 0 + && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && parsed.stream)); + if (protocolRecoveryCandidate) { + upstreamResponse = deferProtocolSafeResetRecovery( + upstreamResponse, + { model: logCtx.model, provider: logCtx.provider }, + (error, stage) => refetchAfterProtocolSafeReset( + (signal = upstream.signal) => fetchWithHeaderTimeout( + request.url, + applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, "connection-reset"), + signal, + connectMs, + true, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + // A replacement HTTP body must not open a fresh WebSocket exchange: the turn it + // replaces was an HTTP stream, and a WS create frame is a different send. + httpOnly: true, + providerName: route.providerName, + modelId: route.modelId, + dispatchOverride: oauthDispatch(request), + beforeDispatch: headers => { + if (signal.aborted) throw signal.reason; + if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) { + throw new Error("Credential selection changed before pre-output stream recovery"); + } + if (isCanonicalOpenAiForwardProvider(route.provider)) { + createCodexReserveDispatchGuard( + admissionState.authCtx, + options.codexAuthPolicy ?? config, + route.modelId, + options.admission, + options.visionDescribeTerminal === true, + )?.(headers); + } + // Recorded with the kind the gate derived its cause from, at the moment the + // send actually leaves. One authorisation, one recorded reason, one send. + transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset"); + // Charged to the SAME request counter every other send goes through. The + // replacement is bought here rather than by a nested retry helper, so there is + // one charge for one send and no per-layer counter to reconcile. + noteTransientSends(1); + }, + }), + route.provider.authMode === "forward", + ).then(adoptObservedResponse), + error, + { + abortSignal: upstream.signal, + label: safeHostLabel(request.url), + attempts: remainingTransientSendBudget(transientSendAttempts()), + // The whole decision, including the stage the preflight observed and the grant the + // pre-header helper shares. A committed stage refuses here without touching the + // allowance, which is what keeps a turn that already emitted output from draining + // the replacement a later ambiguous reset would have been entitled to. + authorize: () => authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed, + acceptResponse: candidate => { + const type = candidate.headers.get("content-type")?.toLowerCase() ?? ""; + return type.includes("text/event-stream") || (!type && parsed.stream); + }, + }, + ), + { allowMissingContentType: !streamRecoveryContentType && parsed.stream }, + ); + } break; } diff --git a/src/server/responses/request-send-budget.ts b/src/server/responses/request-send-budget.ts index 9250162da26..44ca16bc4d1 100644 --- a/src/server/responses/request-send-budget.ts +++ b/src/server/responses/request-send-budget.ts @@ -139,6 +139,16 @@ export function createResponsesSendBudget( */ const sendBudgetExhausted = (cap: number = TRANSIENT_RETRY_MAX_ATTEMPTS): boolean => remainingTransientSendBudget(cap) === 0; + /** + * Spend one operator-granted replacement for an ambiguous failure of THIS logical request. + * + * The counter is the execution budget's, so a combo child that derives its own scope draws on + * the same grant. A budget that predates it -- a stub, or a caller that passed the narrow + * holder -- cannot grant anything, and refusing is the fail-closed answer for a send whose + * upstream state is unknown. + */ + const claimAmbiguousResend = (limit: number): boolean => + isRequestExecutionBudget(sendBudget) && sendBudget.claimAmbiguousResend?.(limit) === true; /** * A credential hop reserves the send its own replay will make, and that replay is a recovery * leg. The leg must SPEND the hop's reservation instead of taking a second one: the @@ -263,6 +273,7 @@ export function createResponsesSendBudget( noteAdapterPhysicalSend, noteAdapterRecoveryWithheld, sendBudgetExhausted, + claimAmbiguousResend, get pendingHopPermit(): SingleUseDispatchPermit | undefined { return pendingHopPermit; }, @@ -301,6 +312,7 @@ function adapterDispatchBudgetView( get targetTransitions(): number { return budget.targetTransitions; }, get lastTargetKey(): string | undefined { return budget.lastTargetKey; }, remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap), + claimAmbiguousResend: (limit: number): boolean => budget.claimAmbiguousResend?.(limit) === true, reserveDispatch(intent: DispatchIntent): DispatchDecision { // A dispatch whose upstream state is unknown is refused on its own merits. A hop that // already paid does not make an unsafe replay safe, so that check stays with the budget. diff --git a/src/server/responses/reset-replay.ts b/src/server/responses/reset-replay.ts new file mode 100644 index 00000000000..b8e47d44b93 --- /dev/null +++ b/src/server/responses/reset-replay.ts @@ -0,0 +1,108 @@ +/** + * The operator opt-in that overrides the stage table's refusal, and the one request-wide + * allowance both ambiguous stages claim from. + * + * `resendPermission` answers `refused-ambiguous` for a native Responses send that died with + * the caller having observed nothing -- before the response head, or after it while the SSE + * body carried only control events. This module is the one place that answer is overridden, + * and it is narrow on three axes at once: the provider has to opt in, the request has to be + * one whose second send cannot do more than run the same inference again, and the whole + * logical request gets a fixed number of replacements no matter how many legs ask. + * + * The judgment is made on the inbound body the client sent, which is already parsed. It is + * conservative for the outbound request: the proxy expands `previous_response_id` and lowers + * hosted tools into client execution, so every hazard that reaches the wire was visible here, + * and a hazard visible here may already have been removed. A cheap fail-closed answer beats + * re-parsing a multi-megabyte outbound body on every send. + */ +import type { OcxProviderConfig } from "../../types"; +import { resetReplayPolicyFor } from "../../providers/key-failover"; +import type { AmbiguousResendAllowance } from "../../lib/request-resend-gate"; + +/** Input items a client owns end to end: replaying them re-runs nothing but the model. */ +const CLIENT_INPUT_ITEM_TYPES: ReadonlySet = new Set([ + "message", "reasoning", "compaction", + "function_call", "function_call_output", + "custom_tool_call", "custom_tool_call_output", + "tool_search_call", +]); +const MESSAGE_ROLES: ReadonlySet = new Set(["user", "assistant", "system", "developer"]); +/** Bounded traversal: a catalog is operator data, not a reason to walk forever. */ +const MAX_TOOL_ENTRIES = 4096; +const MAX_TOOL_DEPTH = 4; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * True when every tool in the catalog is executed by the client. Hosted tools (`web_search`, + * `mcp`, `code_interpreter`, ...) run on the origin during the turn, so an unknown or hosted + * type fails the whole catalog rather than being skipped: a tool this proxy does not + * recognise is a tool it cannot vouch for. + */ +function clientExecutedTools(tools: unknown, budget: { remaining: number }, depth = 0): boolean { + if (!Array.isArray(tools) || depth > MAX_TOOL_DEPTH) return false; + return tools.every(tool => { + budget.remaining -= 1; + if (budget.remaining < 0 || !record(tool)) return false; + if (tool.type === "function" || tool.type === "custom") return true; + if (tool.type === "tool_search") return tool.execution === "client"; + return tool.type === "namespace" && typeof tool.name === "string" + && clientExecutedTools(tool.tools, budget, depth + 1); + }); +} + +/** + * A Responses body whose second send can only repeat the inference: nothing stored, no + * server-side continuation state, complete input, and only client-executed tools. Deferred + * tool declarations inside `input` are checked by the same rule as the root catalog, so a + * hosted tool cannot ride in through `additional_tools` or a `tool_search_output`. + */ +export function selfContainedResponsesBody(body: unknown): boolean { + if (!record(body)) return false; + if (body.store !== false || body.background === true) return false; + if (body.previous_response_id != null || body.conversation != null || Object.hasOwn(body, "stream_id")) return false; + const input = body.input; + if (typeof input !== "string" && !Array.isArray(input)) return false; + const budget = { remaining: MAX_TOOL_ENTRIES }; + if (body.tools !== undefined && !clientExecutedTools(body.tools, budget)) return false; + if (typeof input === "string") return true; + return input.every(item => { + if (!record(item)) return false; + if (item.type === "additional_tools" || item.type === "tool_search_output") { + return clientExecutedTools(item.tools, budget); + } + if (item.type === undefined) return typeof item.role === "string" && MESSAGE_ROLES.has(item.role); + return typeof item.type === "string" && CLIENT_INPUT_ITEM_TYPES.has(item.type); + }); +} + +/** + * The allowance for ONE logical request, or nothing when the provider did not opt in. + * + * Built once per request and handed to every leg. `claim` spends the request's counter, which + * lives on the execution budget and is therefore shared with a combo child's derived scope -- + * that sharing is the reason the pre-header helper takes a callback instead of a number. + * + * The body judgment is carried rather than applied here, because the gate has to be able to + * say WHY it refused: "the operator granted nothing" and "this request cannot be replayed" are + * different operator problems, and folding them together is what made the old refusal a single + * undifferentiated no. + * + * `selfContained` is a predicate rather than a body, and the getter below is why: a provider + * that never opted in must not pay to walk the input array, and the caller memoizes one answer + * across every leg of the request. + */ +export function ambiguousResendAllowanceFor( + provider: Pick, + requestIsSelfContained: () => boolean, + claim: (limit: number) => boolean, +): AmbiguousResendAllowance | undefined { + const policy = resetReplayPolicyFor(provider); + if (policy === null) return undefined; + return { + get selfContained(): boolean { return requestIsSelfContained(); }, + claim: () => claim(policy.replacements), + }; +} diff --git a/src/types.ts b/src/types.ts index 747fc17c756..a779713dcc9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,6 +106,7 @@ export type { ResponsesItemIdRepairConfig, RateLimitRetryPolicy, TransientRetryPolicy, + ResetReplayPolicy, ProviderWebSearchBridgeBackend, ProviderWebSearchBridgeConfig, ProviderCostOverlay, diff --git a/src/types/provider.ts b/src/types/provider.ts index 5e0bd79b583..3dbf793d9fd 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -67,6 +67,31 @@ export interface TransientRetryPolicy { attempts?: number; } +/** + * Opt-in replacement of a native Responses send whose upstream connection closed while the + * caller had observed nothing (`providers..retryOnReset`). + * + * Covers both ambiguous stages the proxy can be in: no response head at all, and a head whose + * SSE body carried only control events. Disabled unless the object is present; a bare `{}` + * opts in with defaults. Only a request the proxy can judge self-contained is ever replaced; + * see `src/server/responses/reset-replay.ts`. The replacement inference may still be billed if + * the origin had already started the first one, which is what makes this opt-in rather than + * default. + */ +export interface ResetReplayPolicy { + /** Master switch. Presence of the object also enables the policy (default true). */ + enabled?: boolean; + /** + * Replacement sends one LOGICAL request may make, across every leg and every combo child + * (1..2, default 1). + * + * Not a per-leg retry count and not a send budget. A request that resets before the head and + * again after it draws on this one number, and each replacement still has to fit inside the + * send allowance the leg already had. + */ + replacements?: number; +} + /** * Same-target 429 wait-and-retry policy (`providers..retryOn429`). When present and not * explicitly disabled, the proxy waits and replays the identical request on the same key before @@ -914,6 +939,12 @@ export interface OcxProviderConfig { * with defaults. Key-auth `openai-chat` only. */ transientRetryOn5xx?: TransientRetryPolicy; + /** + * Opt-in replacement of a native Responses send that died while the caller had observed + * nothing (`providers..retryOnReset`). Disabled unless present; a bare `{}` opts in + * with defaults. Native Responses sends only, and only for self-contained requests. + */ + retryOnReset?: ResetReplayPolicy; /** * Model ids whose OpenAI-compatible chat endpoint accepts `reasoning_split: true` and returns * thinking separately in `reasoning_content` / `reasoning_details` instead of visible content. diff --git a/structure/overview.md b/structure/overview.md index db168062a43..67fb90bfaa1 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -145,6 +145,13 @@ still cover the rule, which is a judgement only review makes. which of the three refusals it is. The decision is derived from per-stage and per-cause facts rather than written out as a stage-by-cause matrix, so a new member cannot leave a stale cell. Enforced by `tests/lib/failure-stage-model.test.ts`. +- **INV-RESEND-02** — One logical request holds one operator-granted replacement for an ambiguous + failure, however many stages ask for it. `src/lib/request-resend-gate.ts` is the only place + that override is applied, it claims the grant at the moment it authorises rather than earlier, + and a stage the caller observed something at refuses without spending it. The grant never + widens a send budget: an authorised replacement still has to fit the allowance the leg already + had. + Enforced by `tests/lib/ambiguous-resend-gate.test.ts`. CI enumerates that domain layout through `scripts/ci/run-bun-test-batches.sh`. Its default general scope and 12-file/120-second process shape leave the dedicated Linux storage-policy and api-usage diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 09bf0d1ccb7..3b40dd43eda 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -849,6 +849,29 @@ compact, and native Chat — are deliberately not opted in. Adapters with their `fetchResponse` (kiro, cursor, google) keep their own retry policies; kiro imports the shared abort/sleep helpers from this module. +## Ambiguous-resend gate + +A model POST that fails with the caller having observed nothing is one question asked at two +points: before any response head, and after a head whose SSE body carried only control events. +`src/lib/request-resend-gate.ts` is the single answer. It derives stage, cause, permission and +send class from `src/lib/request-failure-model.ts` and adds exactly one thing the table names +but does not implement — the narrowly scoped operator override for `refused-ambiguous`. + +The override is bounded on three axes at once. The provider opts in with +`providers..retryOnReset`; the request must be one +`src/server/responses/reset-replay.ts` can judge self-contained, meaning nothing stored, no +server-side continuation state, complete input and only client-executed tools; and the whole +logical request holds one replacement grant, whichever stage asks for it. The grant lives on the +request's execution budget, so a combo child that derives its own scope draws on the same +counter rather than holding a second. A replacement never widens a send budget: it still has to +fit inside the allowance the leg already had, and it is charged to the same counter every other +send goes through. + +A committed or futile failure refuses without touching the grant, so a turn that already emitted +output cannot drain the replacement a later ambiguous reset would have been entitled to. The +cause is derived from the `AttemptRecoveryKind` the send will be recorded as, which is what +keeps the reason in the log and the reason the gate weighed from being two different values. + ## Console upload rejection recovery `src/providers/opencode-zen-rate-limit.ts` recognizes the complete Console upload-rejection envelope only at the effective HTTPS opencode.ai Zen/Go generation endpoint. A provider row name cannot authorize another destination. The two recovery loops in `src/server/responses/core.ts` wait 800 ms and replay the captured serialized request once; cancellation, nonreplayable responses, other errors and a second upload rejection keep their failure semantics. The recovery kind is persisted as `console-go-upload-retry` and has a localized Logs label. @@ -899,6 +922,13 @@ An HTTP 200 does not by itself commit a streaming combo child. The combo parent downstream Responses SSE through `src/server/responses/combo-stream-preflight.ts`, which owns one reader and buffers only until one of these boundaries: +Native post-header reset recovery shares that boundary, because it is asking the same question +about the same bytes. With `replayReadErrors`, the preflight reports the stage it observed — +`headers-only` before any parsed event, `protocol-prelude` after `response.created`, +`semantic-output` once anything else arrives, including a payload it could not parse — and the +resend gate decides. A `response.created` whose snapshot already carries output items is not a +prelude. + - a non-control Responses event begins client-visible output or a tool/action item, after which the target is committed and cross-target replay is forbidden; - a `response.failed` terminal arrives first, in which case the terminal is converted back through @@ -1012,7 +1042,8 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement and same-target request identity. | | `request-sidecar-auth.ts` | Sidecar credential resolution and vision preprocessing. | | `response-effects.ts` | Completion notification, replay publication and live request-tool aliases. | -| `request-send-budget.ts` | Request-wide send accounting, remaining allowance and the pending recovery permit. | +| `request-send-budget.ts` | Request-wide send accounting, remaining allowance, the pending recovery permit and the shared ambiguous-resend grant. | +| `reset-replay.ts` | The operator opt-in for replacing an ambiguous native Responses send, and the per-request grant both stages claim from. | | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | @@ -1250,7 +1281,11 @@ turn up to four more times, and a 429 is where the client stops. `upstream_reset_replay_refused`. No response headers is not evidence that the model POST was never processed, so the decision not to replay is ours, made before any response existed — the same shape as `request_send_budget_exhausted`, and it takes the same status -for the same reason. Only an explicitly replay-safe operation opts into reset retries. +for the same reason. An explicitly replay-safe operation retries instead, and a provider that +opted into `retryOnReset` may spend the request's single replacement grant; once that grant is +gone, or the leg has no send left, or a later attempt fails any other way, the leg settles as +this same refusal. Nothing on that path hands the client a status that invites the whole turn +to be sent again. See [ambiguous-resend gate](#ambiguous-resend-gate). **An upstream reset observed mid-stream or after a terminal keeps its existing behaviour.** The passthrough read path still settles a genuine upstream reset as a synthetic 502, and the diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ab4f3350083..11019881a43 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1445,5 +1445,8 @@ "claude-intercept-settings.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", "claude-desktop-mode-explanation.test.ts": "claude-integration", - "claude-intercept-integration.test.ts": "server" + "claude-intercept-integration.test.ts": "server", + "management-provider-reset-replay.test.ts": "server", + "responses-reset-replay.test.ts": "responses", + "ambiguous-resend-gate.test.ts": "lib" } diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 6b1aa1e5d63..36b44511c96 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -37,6 +37,7 @@ export const RESPONSES_CORE_MODULES = [ "request-spend.ts", "passthrough-execution.ts", "passthrough-dispatch.ts", + "reset-replay.ts", "passthrough-delivery.ts", "sidecar-execution.ts", "completion-policy.ts", diff --git a/tests/lib/ambiguous-resend-gate.test.ts b/tests/lib/ambiguous-resend-gate.test.ts new file mode 100644 index 00000000000..8f8bd7dea45 --- /dev/null +++ b/tests/lib/ambiguous-resend-gate.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { + authorizeResend, + authorizeResendForRecovery, + RESEND_REFUSALS, + type AmbiguousResendAllowance, +} from "../../src/lib/request-resend-gate"; +import { + causeDisposition, + causeForRecoveryKind, + permitsResend, + REQUEST_FAILURE_CAUSES, + REQUEST_FAILURE_STAGES, + resendPermission, + resendSendClass, + stageCommitment, +} from "../../src/lib/request-failure-model"; +import { ATTEMPT_RECOVERY_KIND_ROSTER } from "../../src/usage/telemetry-contract"; + +/* + * Holds INV-RESEND-02 from structure/overview.md. + */ + +/** + * A grant with a counted `claim`. `limit` is the number of replacements the whole logical + * request may make, which is the property every test below is really about. + */ +function allowance(options: { selfContained?: boolean; limit?: number } = {}): AmbiguousResendAllowance & { + claims: () => number; +} { + let claimed = 0; + const limit = options.limit ?? 1; + return { + selfContained: options.selfContained ?? true, + claim: () => { + if (claimed >= limit) return false; + claimed += 1; + return true; + }, + claims: () => claimed, + }; +} + +describe("authorizeResend over the whole stage-by-cause cross product", () => { + test("agrees with the table wherever the table already answers", () => { + for (const stage of REQUEST_FAILURE_STAGES) { + for (const cause of REQUEST_FAILURE_CAUSES) { + const permission = resendPermission(stage, cause); + const decision = authorizeResend(stage, cause); + expect(decision.permission).toBe(permission); + expect(decision.stage).toBe(stage); + expect(decision.cause).toBe(cause); + // The gate adds an override for exactly one answer and changes none of the others. + if (permission !== "refused-ambiguous") { + expect(decision.allowed).toBe(permitsResend(permission)); + } + if (decision.allowed) expect(decision.sendClass).toBe(resendSendClass(cause)); + } + } + }); + + test("a stage the caller observed something at refuses whatever the operator granted", () => { + const grant = allowance({ limit: 5 }); + for (const stage of REQUEST_FAILURE_STAGES) { + if (stageCommitment(stage) === "nothing-observed") continue; + for (const cause of REQUEST_FAILURE_CAUSES) { + const decision = authorizeResend(stage, cause, grant); + expect(decision.allowed).toBe(false); + if (!decision.allowed) expect(decision.refusal).toBe("committed"); + } + } + // Nothing committed may drain the grant a later ambiguous failure is entitled to. + expect(grant.claims()).toBe(0); + }); + + test("a futile cause refuses without spending the grant", () => { + const grant = allowance({ limit: 5 }); + for (const cause of REQUEST_FAILURE_CAUSES) { + if (causeDisposition(cause) !== "resend-is-futile") continue; + const decision = authorizeResend("pre-header", cause, grant); + expect(decision.allowed).toBe(false); + if (!decision.allowed) expect(decision.refusal).toBe("futile"); + } + expect(grant.claims()).toBe(0); + }); + + test("every refusal the gate can produce is a declared member", () => { + const produced = new Set(); + const cases: Array = [ + undefined, + allowance({ selfContained: false }), + allowance({ limit: 0 }), + ]; + for (const stage of REQUEST_FAILURE_STAGES) { + for (const cause of REQUEST_FAILURE_CAUSES) { + for (const grant of cases) { + const decision = authorizeResend(stage, cause, grant); + if (!decision.allowed) produced.add(decision.refusal); + } + } + } + for (const refusal of produced) expect(RESEND_REFUSALS).toContain(refusal); + // Every declared member is reachable, so the roster is not carrying a dead name. + expect([...RESEND_REFUSALS].sort()).toEqual([...produced].sort()); + }); +}); + +describe("the ambiguous override", () => { + const ambiguous = REQUEST_FAILURE_STAGES.filter(stage => stageCommitment(stage) === "nothing-observed"); + + test("refuses with no operator policy and never claims", () => { + for (const stage of ambiguous) { + const decision = authorizeResend(stage, "transport-ambiguous"); + expect(decision.allowed).toBe(false); + if (!decision.allowed) expect(decision.refusal).toBe("ambiguous-no-policy"); + } + }); + + test("refuses a request it cannot judge replayable, without spending the grant", () => { + const grant = allowance({ selfContained: false, limit: 3 }); + for (const stage of ambiguous) { + const decision = authorizeResend(stage, "transport-ambiguous", grant); + expect(decision.allowed).toBe(false); + if (!decision.allowed) expect(decision.refusal).toBe("ambiguous-request-not-replayable"); + } + expect(grant.claims()).toBe(0); + }); + + test("one logical request buys one replacement, whichever stage asks first", () => { + // The defect this gate exists to prevent: #4942 asked before the response head and #4989 + // asked after it, and two separate grants would let one turn be sent twice more. + const grant = allowance({ limit: 1 }); + const first = authorizeResend("pre-header", "transport-ambiguous", grant); + expect(first.allowed).toBe(true); + if (first.allowed) expect(first.spentOperatorAllowance).toBe(true); + + for (const stage of ambiguous) { + const later = authorizeResend(stage, "transport-ambiguous", grant); + expect(later.allowed).toBe(false); + if (!later.allowed) expect(later.refusal).toBe("ambiguous-allowance-spent"); + } + expect(grant.claims()).toBe(1); + }); + + test("a grant of two is spent exactly twice", () => { + const grant = allowance({ limit: 2 }); + expect(authorizeResend("pre-header", "transport-ambiguous", grant).allowed).toBe(true); + expect(authorizeResend("protocol-prelude", "transport-ambiguous", grant).allowed).toBe(true); + expect(authorizeResend("headers-only", "transport-ambiguous", grant).allowed).toBe(false); + expect(grant.claims()).toBe(2); + }); +}); + +describe("authorizeResendForRecovery", () => { + test("derives the cause from the kind it will be recorded as, for every kind", () => { + for (const kind of ATTEMPT_RECOVERY_KIND_ROSTER) { + const decision = authorizeResendForRecovery("pre-header", kind, allowance({ limit: 99 })); + expect(decision.cause).toBe(causeForRecoveryKind(kind)); + expect(decision.recoveryKind).toBe(kind); + // The answer is the table's, asked in the other vocabulary. + expect(decision.permission).toBe(resendPermission("pre-header", causeForRecoveryKind(kind))); + } + }); + + test("a connection reset is the ambiguous row at both observable-nothing stages", () => { + for (const stage of ["pre-header", "headers-only", "protocol-prelude"] as const) { + expect(authorizeResendForRecovery(stage, "connection-reset").permission).toBe("refused-ambiguous"); + expect(authorizeResendForRecovery(stage, "connection-reset", allowance()).allowed).toBe(true); + } + expect(authorizeResendForRecovery("semantic-output", "connection-reset", allowance()).allowed).toBe(false); + }); +}); diff --git a/tests/lib/execution-budget-permits.test.ts b/tests/lib/execution-budget-permits.test.ts index 3f784e3c145..9f7f96b0ec8 100644 --- a/tests/lib/execution-budget-permits.test.ts +++ b/tests/lib/execution-budget-permits.test.ts @@ -482,3 +482,38 @@ describe("derived scopes and the durable spend observer", () => { expect(parent.used).toBe(0); }); }); + +describe("the ambiguous-resend allowance", () => { + test("one logical request holds one grant, and a derived scope shares it", () => { + // The reason the grant lives here rather than beside the policy that issues it: a combo + // child derives its own budget, and two grants would let one turn replace an + // unknown-state send twice -- once on the parent leg, once on the child's. + const parent = createRequestExecutionBudget(CODEX_TEXT_GUARDED_BUDGET_POLICY); + const child = deriveRequestExecutionBudget(parent, CODEX_TEXT_GUARDED_BUDGET_POLICY); + + expect(parent.claimAmbiguousResend?.(1)).toBe(true); + expect(child.claimAmbiguousResend?.(1)).toBe(false); + expect(parent.claimAmbiguousResend?.(1)).toBe(false); + // Raising the ceiling releases exactly the difference, not a fresh grant. + expect(child.claimAmbiguousResend?.(2)).toBe(true); + expect(child.claimAmbiguousResend?.(2)).toBe(false); + }); + + test("a grant is not a send, and a spent send budget is not a spent grant", () => { + const budget = createRequestExecutionBudget(ONE_SEND_LEFT); + expect(budget.reserveDispatch({ sendClass: "initial", targetKey: "t" }).allowed).toBe(true); + expect(budget.remainingBaseSends(5)).toBe(0); + // The grant survives, because it authorises nothing by itself: the send it would fund + // still has to fit in the allowance, which is the caller's check. + expect(budget.claimAmbiguousResend?.(1)).toBe(true); + expect(budget.used).toBe(1); + }); + + test("a ceiling of zero or a nonsense ceiling grants nothing", () => { + const budget = createRequestExecutionBudget(CODEX_TEXT_GUARDED_BUDGET_POLICY); + expect(budget.claimAmbiguousResend?.(0)).toBe(false); + expect(budget.claimAmbiguousResend?.(Number.NaN)).toBe(false); + expect(budget.claimAmbiguousResend?.(Number.POSITIVE_INFINITY)).toBe(false); + expect(budget.claimAmbiguousResend?.(1)).toBe(true); + }); +}); diff --git a/tests/lib/upstream-retry.test.ts b/tests/lib/upstream-retry.test.ts index e8d09b309e3..f55d8ecf300 100644 --- a/tests/lib/upstream-retry.test.ts +++ b/tests/lib/upstream-retry.test.ts @@ -509,6 +509,101 @@ describe("ambiguous reset safety", () => { }); }); +describe("operator-granted replacement of an ambiguous reset", () => { + test("no claim callback keeps the refusal and never sends again", async () => { + const mock = mockDoFetch([bunResetError(), new Response("duplicate")]); + const response = await fetchWithResetRetry(mock.doFetch, { attempts: 3 }); + expect(response.status).toBe(429); + expect(mock.calls).toHaveLength(1); + }); + + test("a granted claim buys exactly one more send and is asked exactly once", async () => { + silenceWarn(); + const reports: number[] = []; + let asked = 0; + const mock = mockDoFetch([bunResetError(), new Response("ok")]); + const response = await fetchWithResetRetry(mock.doFetch, { + attempts: 3, + onSendsConsumed: count => reports.push(count), + claimAmbiguousResend: () => { asked += 1; return asked === 1; }, + }); + expect(await response.text()).toBe("ok"); + expect(mock.calls).toHaveLength(2); + expect(asked).toBe(1); + expect(reports).toEqual([1, 1]); + }); + + test("a spent grant settles as the refusal rather than sending again", async () => { + silenceWarn(); + const mock = mockDoFetch([bunResetError(), bunResetError(), new Response("duplicate")]); + const response = await fetchWithResetRetry(mock.doFetch, { + attempts: 3, + // The shape a request-wide allowance of one produces on its second question. + claimAmbiguousResend: (() => { let left = 1; return () => left-- > 0; })(), + }); + expect(response.status).toBe(429); + expect(isNonReplayableResponse(response)).toBe(true); + expect((await response.json()).error.code).toBe(UPSTREAM_RESET_REPLAY_REFUSED_CODE); + expect(mock.calls).toHaveLength(2); + }); + + test("the grant never widens the send budget it was given", async () => { + const mock = mockDoFetch([bunResetError(), new Response("duplicate")]); + let asked = 0; + const response = await fetchWithResetRetry(mock.doFetch, { + attempts: 1, + claimAmbiguousResend: () => { asked += 1; return true; }, + }); + expect(response.status).toBe(429); + expect(mock.calls).toHaveLength(1); + // Asking would have spent the request's one replacement on a send there was no room for. + expect(asked).toBe(0); + }); + + test("a replay-safe operation never consults the grant", async () => { + silenceWarn(); + let asked = 0; + const mock = mockDoFetch([bunResetError(), new Response("ok")]); + const response = await fetchWithResetRetry(mock.doFetch, { + attempts: 3, replaySafe: true, claimAmbiguousResend: () => { asked += 1; return true; }, + }); + expect(await response.text()).toBe("ok"); + expect(asked).toBe(0); + }); + + test("a non-reset failure after a replacement settles as the refusal, not a rejection", async () => { + silenceWarn(); + // The hazard the refusal exists for: a thrown transport error here becomes a 502 at the + // caller, and a 502 is what the Codex client retries -- so the turn whose first send may + // already have run would be sent again, four more times. + const mock = mockDoFetch([bunResetError(), new Error("upstream fetch failed")]); + const response = await fetchWithResetRetry(mock.doFetch, { + attempts: 3, claimAmbiguousResend: () => true, + }); + expect(response.status).toBe(429); + expect(isNonReplayableResponse(response)).toBe(true); + expect((await response.json()).error.code).toBe(UPSTREAM_RESET_REPLAY_REFUSED_CODE); + expect(mock.calls).toHaveLength(2); + }); + + test("the transient layer carries the grant into its inner reset layer", async () => { + silenceWarn(); + const reports: number[] = []; + const mock = mockDoFetch([ + new Response("busy", { status: 503 }), bunResetError(), new Response("ok"), + ]); + const response = await fetchWithTransientRetry(mock.doFetch, { + attempts: 3, + onSendsConsumed: count => reports.push(count), + claimAmbiguousResend: () => true, + }); + expect(await response.text()).toBe("ok"); + expect(mock.calls).toHaveLength(3); + // One report, from the one layer that owns the budget: three sends, counted once each. + expect(reports).toEqual([3]); + }); +}); + describe("ambiguous reset safety through error formatting", () => { test("every terminal code survives formatting without advertising Retry-After", async () => { for (const code of ["upstream_no_response", "upstream_closed_before_response", "upstream_reset_replay_refused"]) { diff --git a/tests/responses/responses-passthrough-transient-policy.test.ts b/tests/responses/responses-passthrough-transient-policy.test.ts index 77bc00487fe..211d30d1ed7 100644 --- a/tests/responses/responses-passthrough-transient-policy.test.ts +++ b/tests/responses/responses-passthrough-transient-policy.test.ts @@ -43,7 +43,12 @@ describe("the Responses passthrough lane reads the provider transient policy", ( }); test("every transient-retry send takes its attempts from that resolver", () => { - const sites = occurrences(packed, "fetchWithTransientRetry("); + // Every helper this lane sends through, not just the one it started with. The post-header + // replacement reaches upstream exactly like the legs above it and has to draw on the same + // resolver; counting only `fetchWithTransientRetry` would let a second send helper be added + // on the constant while this file still reported balance. + const sites = occurrences(packed, "fetchWithTransientRetry(") + + occurrences(packed, "refetchAfterProtocolSafeReset("); // The lane's initial send plus its recovery legs. A site that stops being counted here is a // site that stopped being governed by the policy. expect(sites).toBeGreaterThanOrEqual(4); diff --git a/tests/responses/responses-reset-replay.test.ts b/tests/responses/responses-reset-replay.test.ts new file mode 100644 index 00000000000..81b252738d6 --- /dev/null +++ b/tests/responses/responses-reset-replay.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import { + ambiguousResendAllowanceFor, + selfContainedResponsesBody, +} from "../../src/server/responses/reset-replay"; +import { authorizeResendForRecovery } from "../../src/lib/request-resend-gate"; + +const clientTurn = { + store: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { type: "function_call", name: "read", call_id: "c1", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + ], + tools: [{ type: "function", name: "read" }], +}; + +describe("selfContainedResponsesBody", () => { + test("accepts a turn whose second send can only repeat the inference", () => { + expect(selfContainedResponsesBody(clientTurn)).toBe(true); + expect(selfContainedResponsesBody({ store: false, input: "plain prompt" })).toBe(true); + expect(selfContainedResponsesBody({ + store: false, + input: [{ role: "user", content: "hi" }], + })).toBe(true); + }); + + test("refuses anything that leaves state behind or continues someone else's turn", () => { + for (const override of [ + { store: true }, + { store: undefined }, + { background: true }, + { previous_response_id: "resp_1" }, + { conversation: "conv_1" }, + { stream_id: undefined }, + { input: undefined }, + { input: { not: "a list" } }, + ]) { + expect(selfContainedResponsesBody({ ...clientTurn, ...override })).toBe(false); + } + expect(selfContainedResponsesBody(null)).toBe(false); + expect(selfContainedResponsesBody([clientTurn])).toBe(false); + }); + + test("refuses a catalog carrying anything the origin would execute", () => { + for (const tools of [ + [{ type: "web_search" }], + [{ type: "function", name: "read" }, { type: "mcp", server_label: "s" }], + [{ type: "tool_search", execution: "server" }], + [{ type: "namespace", name: "ns", tools: [{ type: "code_interpreter" }] }], + [{ type: "namespace" }], + "not a list", + ]) { + expect(selfContainedResponsesBody({ ...clientTurn, tools })).toBe(false); + } + expect(selfContainedResponsesBody({ + ...clientTurn, + tools: [{ type: "namespace", name: "ns", tools: [{ type: "function", name: "read" }] }], + })).toBe(true); + }); + + test("a hosted tool cannot ride in through a deferred declaration", () => { + expect(selfContainedResponsesBody({ + ...clientTurn, + input: [...clientTurn.input, { type: "additional_tools", tools: [{ type: "web_search" }] }], + })).toBe(false); + expect(selfContainedResponsesBody({ + ...clientTurn, + input: [...clientTurn.input, { type: "tool_search_output", tools: [{ type: "function", name: "read" }] }], + })).toBe(true); + }); + + test("an input item type this proxy does not recognise is refused", () => { + expect(selfContainedResponsesBody({ + ...clientTurn, + input: [{ type: "image_generation_call", id: "ig1" }], + })).toBe(false); + }); +}); + +describe("ambiguousResendAllowanceFor", () => { + test("absent or disabled policy grants nothing", () => { + const claim = () => true; + expect(ambiguousResendAllowanceFor({}, () => true, claim)).toBeUndefined(); + expect(ambiguousResendAllowanceFor({ retryOnReset: { enabled: false } }, () => true, claim)).toBeUndefined(); + }); + + test("a bare opt-in spends one replacement for the whole request", () => { + const limits: number[] = []; + let left = 1; + const grant = ambiguousResendAllowanceFor({ retryOnReset: {} }, () => true, limit => { + limits.push(limit); + return left-- > 0; + })!; + expect(grant.selfContained).toBe(true); + expect(grant.claim()).toBe(true); + expect(grant.claim()).toBe(false); + // Both questions were asked at the same ceiling, against the one request-wide counter. + expect(limits).toEqual([1, 1]); + }); + + test("the body judgment is lazy and reaches the gate as a refusal reason", () => { + let judged = 0; + const grant = ambiguousResendAllowanceFor({ retryOnReset: {} }, () => { judged += 1; return false; }, () => true)!; + expect(judged).toBe(0); + const decision = authorizeResendForRecovery("pre-header", "connection-reset", grant); + expect(decision.allowed).toBe(false); + if (!decision.allowed) expect(decision.refusal).toBe("ambiguous-request-not-replayable"); + expect(judged).toBe(1); + }); + + test("a provider that opted in still funds only what the operator asked for", () => { + const seen: number[] = []; + const grant = ambiguousResendAllowanceFor( + { retryOnReset: { replacements: 2 } }, + () => true, + limit => { seen.push(limit); return true; }, + )!; + expect(grant.claim()).toBe(true); + expect(seen).toEqual([2]); + }); +}); diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 798cce0ef22..29e6d34c3e7 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -1,8 +1,10 @@ import { describe, expect, spyOn, test } from "bun:test"; import { comboStreamPayloadCommitsOutput, + deferProtocolSafeResetRecovery, preflightComboStreamResponse, } from "../../src/server/responses/combo-stream-preflight"; +import { stageCommitment, type RequestFailureStage } from "../../src/lib/request-failure-model"; import type { RequestLogContext } from "../../src/server/request-log"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../../src/server/sse-frame-buffer"; @@ -538,7 +540,7 @@ describe("combo stream preflight", () => { expect(source.cancelSpy()!.mock.calls).toHaveLength(0); }); - test("replayReadErrors accepts a reconstructed prefix and the same reader.read error", async () => { + test("replayReadErrors returns a reconstructed prefix, the same read error, and the observed stage", async () => { const readError = new Error("preflight-read-reset"); const source = prefixThenReadError(createdPrefix, readError); const result = await preflightComboStreamResponse( @@ -547,7 +549,14 @@ describe("combo stream preflight", () => { undefined, { replayReadErrors: true }, ); - expect(result.kind).toBe("accepted"); + expect(result.kind).toBe("read-error"); + if (result.kind === "read-error") { + expect(result.error).toBe(readError); + // response.created and nothing else: the failure model puts that in the prelude, and a + // prelude is a stage at which the caller has observed nothing. + expect(result.stage).toBe("protocol-prelude"); + expect(stageCommitment(result.stage)).toBe("nothing-observed"); + } expect(source.cancelSpy()).toBeDefined(); expect(source.cancelSpy()!.mock.calls).toHaveLength(0); const reader = result.response.body!.getReader(); @@ -559,4 +568,88 @@ describe("combo stream preflight", () => { expect(source.cancelSpy()!.mock.calls).toHaveLength(0); }); + test("a read error before any event is headers-only, and a committed stream never reports one", async () => { + const readError = new Error("preflight-read-reset"); + const bare = await preflightComboStreamResponse( + prefixThenReadError(new TextEncoder().encode(""), readError).response, + { model: "m1", provider: "a" }, + undefined, + { replayReadErrors: true }, + ); + expect(bare.kind).toBe("read-error"); + if (bare.kind === "read-error") { + expect(bare.stage).toBe("headers-only"); + expect(stageCommitment(bare.stage)).toBe("nothing-observed"); + } + + // Once output commits the preflight stops buffering and hands the body back, so the read + // error that follows happens on the caller's side of the boundary and no stage is ever + // reported. That is the stronger statement: a committed stream does not reach the resend + // gate at all, rather than reaching it and being refused there. + const outputPrefix = new TextEncoder().encode(`data: ${JSON.stringify({ + type: "response.output_text.delta", delta: "hi", + })}\n\n`); + const committed = await preflightComboStreamResponse( + prefixThenReadError(outputPrefix, readError).response, + { model: "m1", provider: "a" }, + undefined, + { replayReadErrors: true }, + ); + expect(committed.kind).toBe("accepted"); + // The prefix is still relayed and the error still reaches whoever reads it. + const reader = committed.response.body!.getReader(); + expect((await reader.read()).value).toEqual(outputPrefix); + await expect(reader.read()).rejects.toBe(readError); + }); + + /** + * The boundary that decides resend permission, asserted where it is actually enforced. + * + * The stage a read error is reported at is only half the guarantee. What matters is that a + * stream which committed output never gets a replacement offered at all, and the seam that + * decides it is the deferred wrapper, not the preflight. The commitment is read from + * `stageCommitment` rather than compared against a written-out stage name, so a stage added + * to the model later cannot pass this by being unlisted. + */ + test("a replacement is offered only for a stage the caller observed nothing at", async () => { + const readError = new Error("preflight-read-reset"); + const logCtx: RequestLogContext = { model: "m1", provider: "a" }; + const seen: RequestFailureStage[] = []; + const recover = async (_error: unknown, stage: RequestFailureStage): Promise => { + seen.push(stage); + return null; + }; + + const prelude = deferProtocolSafeResetRecovery( + prefixThenReadError(createdPrefix, readError).response, logCtx, recover); + const preludeReader = prelude.body!.getReader(); + expect((await preludeReader.read()).value).toEqual(createdPrefix); + await expect(preludeReader.read()).rejects.toBe(readError); + expect(seen).toHaveLength(1); + expect(stageCommitment(seen[0]!)).toBe("nothing-observed"); + + seen.length = 0; + const outputPrefix = new TextEncoder().encode(`data: ${JSON.stringify({ + type: "response.output_text.delta", delta: "hi", + })}\n\n`); + const committed = deferProtocolSafeResetRecovery( + prefixThenReadError(outputPrefix, readError).response, logCtx, recover); + const committedReader = committed.body!.getReader(); + expect((await committedReader.read()).value).toEqual(outputPrefix); + await expect(committedReader.read()).rejects.toBe(readError); + // Never consulted. A turn whose output the caller already saw cannot be replaced, and it + // does not get as far as asking. + expect(seen).toEqual([]); + }); + + test("a response.created carrying output is not a prelude", () => { + expect(comboStreamPayloadCommitsOutput({ + type: "response.created", response: { id: "r1", output: [] }, + })).toBe(false); + expect(comboStreamPayloadCommitsOutput({ + type: "response.created", + response: { id: "r1", output: [{ type: "message", role: "assistant" }] }, + })).toBe(true); + }); + }); diff --git a/tests/server/management-provider-reset-replay.test.ts b/tests/server/management-provider-reset-replay.test.ts new file mode 100644 index 00000000000..27f7f2c2df5 --- /dev/null +++ b/tests/server/management-provider-reset-replay.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { providerManagementConfigError } from "../../src/server/auth-cors"; + +// Lives apart from management-provider-validation.test.ts because that file sits at its +// file-size ratchet cap; the helpers it needs are small enough to repeat here. +const canonicalDirect = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", +} as const; + +test("provider management validates retryOnReset bounds and unknown keys", () => { + const base = { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1" }; + expect(providerManagementConfigError("custom", { ...base, retryOnReset: {} })).toBeNull(); + expect(providerManagementConfigError("custom", { ...base, retryOnReset: { enabled: true, replacements: 2 } })).toBeNull(); + expect(providerManagementConfigError("custom", { ...base, retryOnReset: { replacements: 0 } })) + .toContain("retryOnReset.replacements is invalid"); + expect(providerManagementConfigError("custom", { ...base, retryOnReset: { replacements: 3 } })) + .toContain("retryOnReset.replacements is invalid"); + // The old field name from the pre-rework branch is rejected rather than silently ignored: + // `attempts` was a per-leg send count and this is a per-request replacement count. + expect(providerManagementConfigError("custom", { ...base, retryOnReset: { attempts: 2 } })) + .toContain("retryOnReset has unrecognized field"); + expect(providerManagementConfigError("custom", { ...base, retryOnReset: true })) + .toContain("retryOnReset is invalid"); + // The canonical openai row is the main target of this policy, and a full-object write + // compares it against the seed with an exact key match: the field must be admitted + // there like requestPacing is, while its value is still validated. + expect(providerManagementConfigError("openai", { ...canonicalDirect, retryOnReset: { replacements: 2 } })).toBeNull(); + expect(providerManagementConfigError("openai", { ...canonicalDirect, retryOnReset: { replacements: 3 } })) + .toContain("retryOnReset.replacements is invalid"); + // A secret-shaped unknown field name and a secret-shaped provider name are both redacted. + const secretError = providerManagementConfigError("custom", { ...base, retryOnReset: { "sk-super-secret-9876": true } })!; + expect(secretError).toContain("retryOnReset has unrecognized field"); + expect(secretError).not.toContain("sk-super-secret-9876"); + const secretNameError = providerManagementConfigError("sk-super-secret-9876", { ...base, retryOnReset: { replacements: 0 } })!; + expect(secretNameError).toContain("retryOnReset.replacements is invalid"); + expect(secretNameError).not.toContain("sk-super-secret-9876"); + expect(secretNameError).toContain("[REDACTED]"); +}); + +test("retryOn429 keeps its own field name after the formatter was shared", () => { + // The two validators now run through one body. A shared formatter that reported the wrong + // field name would send an operator to the wrong key in their config. + const base = { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1" }; + expect(providerManagementConfigError("custom", { ...base, retryOn429: { attempts: 0 } })) + .toContain("retryOn429.attempts is invalid"); + expect(providerManagementConfigError("custom", { ...base, retryOn429: { nope: 1 } })) + .toContain("retryOn429 has unrecognized field"); +}); From 9d7d7eb5befdda112d9ca86609a558e6f843f595 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:06:44 +0900 Subject: [PATCH 39/87] fix: make a failed browser launch and a failed account refresh visible (#5261) (#5332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(oauth): say when a CLI login's browser never opened `openUrl` reports whether the OS launcher actually started, and the Codex account login prints it. The two generic CLI logins still called `void openUrl(...)`, so on a host with no browser both announced that they were opening one, printed a URL, and then asked a question that assumes it opened. Nothing distinguishes that from a login that is working, which is the whole of #5261. Reporting it is an ordering problem, not a message problem. The OAuth controller does not await `onAuth` — every provider calls it as `ctrl.onAuth?.(...)` and moves on — so the launcher's answer arrives after the flow has continued, and on a callback-server provider it has already drawn a readline prompt by then. A warning written at that moment lands on the line the user is typing on. So the launch reports itself as soon as it settles, and the manual-code prompt waits on that report before asking; the key login awaits it before it creates a reader at all. The sentence itself is now stated once. `BROWSER_LAUNCH_FAILED_HINT` keeps its ChatGPT-specific second line about the fixed callback port and `--device`, but derives its first line from the shared notice instead of repeating it. The handlers take an optional deps object because the contract worth holding is an order, and an order is only observable from something that records both events. Production passes none of them. * fix(gui): stop a failed account refresh from reading as a current roster After a successful load, a failed account read left the Codex pool reporting `ready` with the rows it had before. Keeping those rows is deliberate — blanking a populated pool because one 30s poll missed is its own defect — but nothing distinguished a list the server had just confirmed from one that predated a failure. The shape a user hits: add an account, the read that would bring it over fails, and the dashboard shows the older accounts with the new one simply absent and no indication that anything went wrong (#5261). The controller now carries `refreshFailed` alongside `loadState`, for the same reason `refreshing` already lives there: `loadState` answers what the surface can draw, and a warm failure does not change that answer. Folding the failure into `loadState` would mean either flashing the cold skeleton over good data or saying nothing, and saying nothing is what this fixes. A cold failure still replaces the surface with its existing error. The pool renders a non-destructive amber status above the rows it is qualifying, with the retry the cold error already offers. It appears only when rows survived, so an empty cold failure still shows its own message rather than a banner describing nothing. The load-states prop also stops restating the load-state union and derives it from the controller instead. * docs(devlog): record lane R3 and correct the stale #5292 row #5292's GUI half landed in #5300 two hours before the plan was written, so the table's description of it is a snapshot, not open work. The lane note says what is already on dev and what holds it there. The two #5261 remainders are recorded with the reason each fix has the shape it does: the OAuth controller does not await onAuth, and the account roster keeps its rows on purpose. --- .../260920_round2_followups/030_lane_r3.md | 90 ++++++ gui/src/components/CodexAccountPool.tsx | 3 +- .../codex-account-pool-main-card.tsx | 17 +- .../components/codex-account-pool-types.ts | 2 +- gui/src/hooks/useCodexAccountPool.ts | 23 +- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + .../styles/provider-workspace-settings.css | 1 + .../codex-account-pool-controller.test.ts | 4 + .../codex-account-pool-stale-refresh.test.tsx | 270 ++++++++++++++++++ .../codex-account-pool-toast-tone.test.tsx | 1 + scripts/test-layout/layout.json | 1 + src/cli/account-auth.ts | 6 +- src/lib/browser-launch-notice.ts | 59 ++++ src/oauth/login-cli.ts | 109 +++++-- tests/fixtures/test-layout-expected.json | 1 + .../oauth-login-cli-browser-launch.test.ts | 242 ++++++++++++++++ 25 files changed, 803 insertions(+), 36 deletions(-) create mode 100644 devlog/_plan/260920_round2_followups/030_lane_r3.md create mode 100644 gui/tests/codex-account-pool-stale-refresh.test.tsx create mode 100644 src/lib/browser-launch-notice.ts create mode 100644 tests/oauth/oauth-login-cli-browser-launch.test.ts diff --git a/devlog/_plan/260920_round2_followups/030_lane_r3.md b/devlog/_plan/260920_round2_followups/030_lane_r3.md new file mode 100644 index 00000000000..d38a851a40f --- /dev/null +++ b/devlog/_plan/260920_round2_followups/030_lane_r3.md @@ -0,0 +1,90 @@ +# R3 — the roster and login remainders + +Status: implemented, awaiting review. Scope was #5292 and the two #5261 remainders. + +## #5292 was already closed before this lane opened + +The plan's table says `gui/src/pages/Logs.tsx` restates the recovery-kind union with nine of +thirteen members. That was true when the table was written and stopped being true two hours +earlier: `555f0cacdf` (#5300, 18:41) replaced the copy with the durable roster, and the plan +commit landed at 20:48 from a snapshot taken before it. + +Current `dev` already has all of it. `Logs.tsx` imports `AttemptRecoveryKind` from +`src/usage/telemetry-contract.ts` and its label map closes with +`satisfies Record`, so a fourteenth kind is a typecheck failure +there rather than an "Unknown recovery reason". All ten catalogs carry all thirteen labels plus +the fallback, and `tests/usage/request-outcome-agreement.test.ts` holds both: the label map has +to cover every member of `ATTEMPT_RECOVERY_KIND_ROSTER`, and every key it names has to exist in +every catalog. Verified by reading the tree, not by rerunning the suite. + +Nothing was changed for it. The row is stale, not open. + +## #5261, remainder one: the two CLI logins that discarded the launch + +`src/oauth/login-cli.ts` called `void openUrl(...)` in both `handleOAuthLogin` and +`handleKeyLogin`. Each printed a URL, said it was opening a browser, and asked a question that +assumes it opened — indistinguishable from a login that is working. + +The part that made this more than a missing `console.warn`: `OAuthController.onAuth` returns +`void` and every one of the thirteen provider call sites invokes it as `ctrl.onAuth?.(...)` and +moves on. The launcher's answer therefore arrives after the flow has continued, and on a +callback-server provider `#waitForCallback` has already called `onManualCodeInput` by then. A +warning written at that moment lands on the line the user is typing on. + +Making `onAuth` awaitable would mean changing the controller contract and all thirteen call +sites, which is a much larger change than the defect deserves. Instead the launch reports itself +when it settles, and the two things that could collide with it wait on that report: the +manual-code prompt awaits it before asking, and the key login awaits it before it constructs a +reader at all. A polling provider that never prompts is still told before the login claims to +have worked. + +`BROWSER_LAUNCH_FAILED_HINT` in `src/cli/account-auth.ts` kept its ChatGPT-specific second line +and now derives its first from `BROWSER_LAUNCH_FAILED_NOTICE`, so the sentence has one home +across all three logins. + +The handlers took an optional deps object. The contract worth holding is an order, and an order +is only observable from something that records both events; spawning a launcher and attaching to +stdin to find that out would test the operating system. Production passes none of them. + +## #5261, remainder two: the roster that kept last-good rows silently + +`useCodexAccountPool` kept its rows after a failed read and also kept reporting `ready`. Keeping +the rows is right — blanking a populated pool because one 30s poll missed is its own defect — but +the surface then could not tell a list the server had just confirmed from one that predated a +failure. The reported shape: add an account, the read that would bring it over fails, and the +older accounts are on screen with the new one absent. + +`refreshFailed` sits beside `loadState` rather than inside it, for the same reason `refreshing` +already does. `loadState` answers what the surface can draw and a warm failure does not change +that answer; folding it in would mean either flashing the cold skeleton over good data or saying +nothing. A cold failure still replaces the surface with the error it already had, and the banner +only renders when rows survived, so an empty cold failure is never annotated instead of explained. + +## Verification + +Static review and hosted CI at the exact head. The lane ran no local suite, no individual test, +no typecheck, no build, no install, no `ocx`, and changed no credential or configuration — +recorded as NOT RUN. + +Checked by reading rather than running, because the ratchets are what a merge breaks: + +- No file this lane touches appears in `tests/fixtures/file-size-baseline.json`. The ten i18n + catalogs are in its `exempt` list. +- `tests/oauth/oauth-login-cli-browser-launch.test.ts` is registered in both + `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. The gui + suite has no layout guard. +- The one new i18n key is in all ten catalogs, which `gui/tests/locale-parity.test.ts` and + `gui/tests/claude-desktop-locale.test.ts` both require. +- `CodexAccountLoadState` gained no member. `CodexAccountPoolController` gained one, and the + source-oracle roster in `gui/tests/codex-account-pool-controller.test.ts` names it. +- `CodexAccountPoolLoadStates` stopped restating the load-state union and derives it. + +## The GUI screenshot gate + +`enforce-target` requires a screenshot for a PR that touches `gui`. Producing one needs +`bun run build:gui` and a running proxy, both of which this lane is forbidden to do, so the pull +request says so and offers what can be checked instead: the rendered markup is asserted against a +mounted DOM in `gui/tests/codex-account-pool-stale-refresh.test.tsx` — the banner appears with +surviving rows, carries the catalog string, does not appear on a successful refresh, and does not +replace the cold error — and the new class reuses the existing `.pwi-auth-state` block with the +`--amber` pair already used elsewhere in the theme. diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index e9be8f96970..20e5532d1da 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -72,7 +72,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // but stays inert (no load, no polling) whenever a shared controller was injected. const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; - const { accounts, activeId, loadState, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; + const { accounts, activeId, loadState, refreshFailed, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; // #3898: the native-main device reauth drives the dedicated namespace; a // completed flow refreshes the account list so the card leaves reauth state. const mainReauth = useMainDeviceReauth(apiBase, () => { void load(); }); @@ -476,6 +476,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban { void load(); }} /> diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 25af1eb49f9..051424184f5 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -3,7 +3,7 @@ import { IconLock, IconPause, IconPlay, IconPlus, IconRefresh, IconTicket } from import AccountPriorityControl, { AccountPriorityBadge } from "./AccountPriorityControl"; import QuotaBars from "./QuotaBars"; import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-helpers"; -import type { CodexAccountEntry } from "./codex-account-pool-types"; +import type { CodexAccountEntry, CodexAccountLoadState } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; import type { MainDeviceReauthState } from "./use-main-device-reauth"; @@ -359,11 +359,13 @@ export function CodexAccountPoolActions(props: { export function CodexAccountPoolLoadStates({ t, loadState, + refreshFailed, accountsCount, onRetry, }: { t: TFn; - loadState: "loading" | "ready" | "error"; + loadState: CodexAccountLoadState; + refreshFailed: boolean; accountsCount: number; onRetry: () => void; }): ReactNode { @@ -419,5 +421,16 @@ export function CodexAccountPoolLoadStates({
); } + // Rows survived a failed refresh, so they are still worth showing — but they are the ones from + // before it, and an account added since is simply not among them. A status rather than an alert: + // nothing on screen is wrong, it is just older than it looks. + if (refreshFailed && accountsCount > 0) { + return ( +
+ {t("codexAuth.accountsRefreshFailed")} + +
+ ); + } return null; } diff --git a/gui/src/components/codex-account-pool-types.ts b/gui/src/components/codex-account-pool-types.ts index 679350fa23f..0e16acca880 100644 --- a/gui/src/components/codex-account-pool-types.ts +++ b/gui/src/components/codex-account-pool-types.ts @@ -1 +1 @@ -export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; +export type { CodexAccountEntry, CodexAccountLoadState } from "../hooks/useCodexAccountPool"; diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index cb5fce92968..fe4cdaa155b 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -97,6 +97,11 @@ export interface CodexAccountPoolController { * `ready` during a refresh so rows survive; this is what makes that wait visible. */ refreshing: boolean; + /** + * The most recent account read failed. The rows it could not replace are still on screen, so + * this is the only thing that tells a surface they are no longer known to be current. + */ + refreshFailed: boolean; /** True until the first load attempt settles, whether it succeeds or fails. */ initialLoading: boolean; switchingId: string | null; @@ -160,6 +165,13 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou ); const [activeId, setActiveId] = useState(() => seed?.activeId ?? null); const [loadState, setLoadState] = useState(() => (seed != null ? "ready" : "loading")); + // Deliberately beside `loadState` rather than inside it. `loadState` answers what the surface + // can draw, and a warm refresh failure keeps the rows drawable — folding the failure in would + // mean either flashing the cold skeleton over good data or, as before, saying nothing at all. + // Saying nothing is the defect: the rows on screen are the ones from before the refresh, so an + // account the user has just added is simply absent while the older ones look current (#5261). + // `refreshing` already set the precedent that a fact about the read lives next to loadState. + const [refreshFailed, setRefreshFailed] = useState(false); const [switchingId, setSwitchingId] = useState(null); const [pauseUpdatingId, setPauseUpdatingId] = useState(null); const [priorityUpdatingId, setPriorityUpdatingId] = useState(null); @@ -277,6 +289,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou hasLoadedRef.current = true; // Progressive: paint account/quota boxes as soon as /accounts returns. setLoadState("ready"); + // Cleared here rather than at the settle below, because the rows it qualifies are + // painted here. Waiting for /active to finish would leave the just-replaced rows + // labelled as pre-refresh ones for as long as that read's budget allows. + setRefreshFailed(false); } return true; } catch { @@ -329,9 +345,11 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou }); return activeOk; } - // Cold failure only: after a successful load (including empty), keep rows and stay ready - // so a soft poll miss does not flash the skeleton / wipe the pool. + // A cold failure has nothing to show, so it replaces the surface. A warm one keeps its rows + // — flashing the skeleton on a soft poll miss is its own defect — and says so instead of + // continuing to present them as current. if (!hasLoadedRef.current) setLoadState("error"); + setRefreshFailed(true); return false; } finally { bounded.clear(); @@ -623,6 +641,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou activeId, loadState, refreshing: inflightCount > 0, + refreshFailed, initialLoading: !firstAttemptSettled, switchingId, pauseUpdatingId, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0bbdef5fbad..d6c3f47ddac 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1672,6 +1672,7 @@ export const de: Record = { "accountPool.priorityUpdateFailed": "Die Auswahlreihenfolge für {email} konnte nicht gespeichert werden. Der zuletzt bestätigte Wert wird angezeigt.", "codexAuth.switched": "{email} ist für die nächste Anfrage ausgewählt", "codexAuth.loadFailed": "Die Codex-Kontoeinstellungen konnten nicht geladen werden.", + "codexAuth.accountsRefreshFailed": "Die letzte Kontoaktualisierung ist fehlgeschlagen. Unten stehen die zuletzt bestätigten Konten.", "codexAuth.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.", "codexAuth.removeConfirm": "{id} entfernen?", "codexAuth.removeFailed": "Das Konto konnte nicht entfernt werden. Es wurde nichts geändert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 929680d998a..e3ac88d19df 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2266,6 +2266,7 @@ export const en = { "codexAuth.switched": "{email} is selected for the next request", "codexAuth.loadFailed": "Codex account settings could not be loaded.", + "codexAuth.accountsRefreshFailed": "The latest account refresh failed. The accounts below are the last ones confirmed.", "codexAuth.switchFailed": "The account could not be switched. Your previous selection is unchanged.", "codexAuth.removeConfirm": "Remove {id}?", "codexAuth.removeFailed": "The account could not be removed. Nothing was changed.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4b14727a4da..5c166a81795 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2188,6 +2188,7 @@ export const fr: Record = { "accountPool.priorityUpdateFailed": "Impossible d’enregistrer l’ordre de sélection de {email}. La dernière valeur confirmée est affichée.", "codexAuth.switched": "{email} est sélectionné pour la prochaine requête", "codexAuth.loadFailed": "Impossible de charger les paramètres des comptes Codex.", + "codexAuth.accountsRefreshFailed": "La dernière actualisation des comptes a échoué. Les comptes ci-dessous sont les derniers confirmés.", "codexAuth.switchFailed": "Impossible de changer de compte. Votre sélection précédente reste inchangée.", "codexAuth.removeConfirm": "Supprimer {id} ?", "codexAuth.removeFailed": "Impossible de supprimer le compte. Aucune modification apportée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index f82461e98ad..adc3439a347 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2116,6 +2116,7 @@ export const ja: Record = { "accountPool.priorityUpdateFailed": "{email} の選択順序を保存できませんでした。最後に確認された値を表示しています。", "codexAuth.switched": "次のリクエストでは {email} を使用します", "codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。", + "codexAuth.accountsRefreshFailed": "最新のアカウント更新に失敗しました。以下は最後に確認されたアカウントです。", "codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。", "codexAuth.removeConfirm": "{id} を削除しますか?", "codexAuth.removeFailed": "アカウントを削除できませんでした。何も変更されていません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 24ce4e2fd53..2e56314db9c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1708,6 +1708,7 @@ export const ko: Record = { "accountPool.priorityUpdateFailed": "{email}의 선택 순서를 저장하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", "codexAuth.switched": "다음 요청에 {email}을(를) 사용합니다", "codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.", + "codexAuth.accountsRefreshFailed": "최신 계정 새로고침에 실패했습니다. 아래 목록은 마지막으로 확인된 계정입니다.", "codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.", "codexAuth.removeConfirm": "{id}을(를) 삭제하시겠습니까?", "codexAuth.removeFailed": "계정을 제거하지 못했습니다. 변경된 내용은 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6dbc8823189..ee734f14fe8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2187,6 +2187,7 @@ export const ru: Record = { "accountPool.priorityUpdateFailed": "Не удалось сохранить порядок выбора для {email}. Показано последнее подтверждённое значение.", "codexAuth.switched": "{email} выбран для следующего запроса", "codexAuth.loadFailed": "Не удалось загрузить настройки аккаунтов Codex.", + "codexAuth.accountsRefreshFailed": "Последнее обновление аккаунтов не удалось. Ниже показаны последние подтверждённые аккаунты.", "codexAuth.switchFailed": "Не удалось переключить аккаунт. Ваш предыдущий выбор не изменён.", "codexAuth.removeConfirm": "Удалить {id}?", "codexAuth.removeFailed": "Не удалось удалить аккаунт. Ничего не изменено.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9a986e74565..91209cc436a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2207,6 +2207,7 @@ export const tr: Record = { "codexAuth.switched": "Sonraki istek için {email} seçildi", "codexAuth.loadFailed": "Codex hesap ayarları yüklenemedi.", + "codexAuth.accountsRefreshFailed": "Son hesap yenilemesi başarısız oldu. Aşağıda son doğrulanan hesaplar gösteriliyor.", "codexAuth.switchFailed": "Hesap değiştirilemedi.", "codexAuth.removeConfirm": "{id} kaldırılsın mı?", "codexAuth.removeFailed": "Hesap kaldırılamadı.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 4f88f218a56..45d48b839aa 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2204,6 +2204,7 @@ export const vi: Record = { "accountPool.priorityUpdateFailed": "Không thể lưu thứ tự lựa chọn cho {email}. Giá trị được xác nhận gần nhất đang được hiển thị.", "codexAuth.switched": "Tài khoản được chọn cho request tiếp theo là {email}", "codexAuth.loadFailed": "Không thể tải cài đặt tài khoản Codex.", + "codexAuth.accountsRefreshFailed": "Lần làm mới tài khoản gần nhất thất bại. Dưới đây là các tài khoản được xác nhận gần nhất.", "codexAuth.switchExceedsThresholdWarning": "Mức sử dụng của tài khoản này đã đạt hoặc vượt ngưỡng chuyển đổi ({threshold}%). Lựa chọn đã ghim sẽ được giải phóng nếu không còn hạn ngạch khả dụng.", "codexAuth.switchFailed": "Không thể chuyển đổi tài khoản. Tùy chọn trước đó của bạn vẫn được giữ nguyên.", "codexAuth.removeConfirm": "Gỡ bỏ {id}?", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 5cff65720cd..e1cf2ca48e5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1671,6 +1671,7 @@ export const zhTW: Record = { "accountPool.quotaWindowInert": "只有配額策略,或門檻大於 0 的填滿優先策略,才會依用量計分;在目前的輪換策略下,這項設定不會有任何作用。", "codexAuth.switched": "下一次請求將使用 {email}", "codexAuth.loadFailed": "無法載入 Codex 帳號設定。", + "codexAuth.accountsRefreshFailed": "最近一次帳號重新整理失敗。以下是最後一次確認的帳號。", "codexAuth.switchFailed": "無法切換帳號。之前的選擇保持不變。", "codexAuth.removeConfirm": "刪除 {id}?", "codexAuth.removeFailed": "無法移除帳號。未進行任何更改。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 37432f6380e..c9dbe680345 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1689,6 +1689,7 @@ export const zh: Record = { "accountPool.priorityUpdateFailed": "无法保存 {email} 的选择顺序。当前显示最后一次确认的值。", "codexAuth.switched": "下一次请求将使用 {email}", "codexAuth.loadFailed": "无法加载 Codex 账号设置。", + "codexAuth.accountsRefreshFailed": "最近一次账号刷新失败。以下是最后一次确认的账号。", "codexAuth.switchFailed": "无法切换账户。之前的选择保持不变。", "codexAuth.removeConfirm": "删除 {id}?", "codexAuth.removeFailed": "无法移除账户。未进行任何更改。", diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 34a9e808971..5dc15873e1f 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -29,6 +29,7 @@ background: var(--raised); border-radius: var(--radius-xs); } .pwi-auth-state--error { color: var(--red); background: var(--red-soft); justify-content: space-between; } +.pwi-auth-state--stale { color: var(--amber); background: var(--amber-soft); justify-content: space-between; } .pwi-auth-state--empty { justify-content: center; } .pwi-auth-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; } diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index 044b8156a01..3f1d5c848d4 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -21,6 +21,10 @@ test("the controller is the single data owner and exposes the agreed contract", // WP2 (260730_gui_hydration_loading_unify/010): progress is part of the contract, because a // forced quota refresh keeps `loadState` at "ready" and would otherwise be invisible. "refreshing", "initialLoading", + // #5261: for the same reason in the other direction. A warm refresh failure keeps the rows + // and keeps `loadState` at "ready", so without this the surface has no way to say that what + // it is showing predates a failed read. + "refreshFailed", ]) { expect(hook).toContain(member); } diff --git a/gui/tests/codex-account-pool-stale-refresh.test.tsx b/gui/tests/codex-account-pool-stale-refresh.test.tsx new file mode 100644 index 00000000000..0f164c71a72 --- /dev/null +++ b/gui/tests/codex-account-pool-stale-refresh.test.tsx @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import CodexAccountPool from "../src/components/CodexAccountPool"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { useCodexAccountPool, type CodexAccountEntry, type CodexAccountPoolController } from "../src/hooks/useCodexAccountPool"; +import { en } from "../src/i18n/en"; +import { LanguageProvider } from "../src/i18n/provider"; + +/** + * #5261: a failed account refresh used to leave the roster looking current. + * + * Keeping the rows is deliberate — blanking a populated pool on a soft poll miss is its own + * defect — but the controller also went on reporting `ready`, so nothing distinguished a list + * the server had just confirmed from one that predated a failure. The case that surfaced it: + * add an account, the read that would bring it over fails, and the dashboard shows the older + * accounts with the new one simply absent. + * + * Both halves are held here because either alone is satisfiable without the other: a flag the + * surface never reads changes nothing a user sees, and a banner with no flag behind it never + * appears. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let accountsOk = true; +let serverAccounts: unknown[] = []; +let baseCounter = 0; +let activeResponseGate: Promise | null = null; + +function row(id: string, email: string, isMain = false) { + return { id, email, isMain, paused: false, priority: 0, hasCredential: true, quota: null }; +} + +const mainAccount: CodexAccountEntry = { + id: "main", + email: "main@example.test", + isMain: true, + paused: false, + priority: 0, + hasCredential: true, + quota: null, + quotaAutoRefresh: { + fiveHourAvailable: false, + weeklyAvailable: false, + fiveHourEnabled: false, + weeklyEnabled: false, + }, +}; + +function makeController(overrides: Partial = {}): CodexAccountPoolController { + return { + accounts: [mainAccount], + activeId: null, + loadState: "ready", + refreshing: false, + refreshFailed: false, + initialLoading: false, + switchingId: null, + pauseUpdatingId: null, + priorityUpdatingId: null, + pausingExhausted: false, + activeNeedsReauth: false, + activePinnedId: null, + load: async () => true, + switchAccount: async () => ({ ok: true, activeId: null }), + setAccountPaused: async () => ({ ok: true }), + setAccountPriority: async () => ({ ok: true }), + pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), + saveAlias: async () => ({ ok: true }), + removeAccount: async () => ({ ok: true }), + syncAfterAccountAdded: async () => ({ ok: true }), + pauseRefresh: () => ({ __brand: "codex-pool-pause" }) as never, + resumeRefresh: () => {}, + subscribeLoadObserver: () => () => {}, + readLastThreshold: () => undefined, + readLastActive: () => undefined, + ...overrides, + }; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + originalFetch = globalThis.fetch; + accountsOk = true; + activeResponseGate = null; + serverAccounts = [row("a1", "account-one", true)]; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string) => { + const path = String(url).split("/api/")[1] ?? String(url); + if (path.startsWith("usage?")) { + return { ok: true, json: async () => ({ accounts: [] }) } as unknown as Response; + } + if (path.startsWith("codex-auth/accounts")) { + if (!accountsOk) return { ok: false, status: 503 } as unknown as Response; + return { ok: true, json: async () => ({ accounts: serverAccounts }) } as unknown as Response; + } + if (path.startsWith("codex-auth/active")) { + const gate = activeResponseGate; + activeResponseGate = null; + if (gate) await gate; + return { + ok: true, + json: async () => ({ activeCodexAccountId: null, autoSwitchThreshold: 80 }), + } as unknown as Response; + } + return { ok: true, json: async () => ({}) } as unknown as Response; + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +/** A fresh apiBase each time: the controller's last-good snapshot is keyed by it. */ +async function mountController() { + baseCounter += 1; + const apiBase = `stale-${Date.now()}-${baseCounter}`; + const seen: { current: CodexAccountPoolController | null } = { current: null }; + function Probe() { + seen.current = useCodexAccountPool(apiBase, true); + return null; + } + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + return seen; +} + +async function mountPool(controller: CodexAccountPoolController) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +function staleBanner(): Element | null { + return host.querySelector(".pwi-auth-state--stale"); +} + +test("a failed refresh keeps the rows and stops reporting them as current", async () => { + const seen = await mountController(); + expect(seen.current!.loadState).toBe("ready"); + expect(seen.current!.refreshFailed).toBe(false); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1"]); + + // The server now has an account this client has never seen, and the read that would have + // brought it over fails. This is the reported shape of the defect, not a synthetic one. + serverAccounts = [row("a1", "account-one", true), row("a2", "account-two")]; + accountsOk = false; + await act(async () => { await seen.current!.load(); }); + + expect(seen.current!.refreshFailed).toBe(true); + // Still ready, and still holding the rows: blanking a populated pool on a miss is its own + // defect, so the fix is that the surface now has something to say, not that it shows less. + expect(seen.current!.loadState).toBe("ready"); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1"]); + + accountsOk = true; + await act(async () => { await seen.current!.load(); }); + + expect(seen.current!.refreshFailed).toBe(false); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1", "a2"]); +}); + +test("the banner clears with the rows it qualifies, not with the whole load", async () => { + // The rows are painted the moment /accounts returns, while /active can still be running on + // its own much longer budget. Clearing the flag at the settle instead would leave the rows + // that just replaced the stale ones labelled as the stale ones for that whole window. + const seen = await mountController(); + + accountsOk = false; + await act(async () => { await seen.current!.load(); }); + expect(seen.current!.refreshFailed).toBe(true); + + accountsOk = true; + serverAccounts = [row("a1", "account-one", true), row("a2", "account-two")]; + let releaseActive!: () => void; + activeResponseGate = new Promise(resolve => { releaseActive = resolve; }); + + let pending: Promise; + await act(async () => { + pending = seen.current!.load(); + await new Promise((r) => setTimeout(r, 10)); + }); + + // /accounts has landed; /active has not. + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1", "a2"]); + expect(seen.current!.refreshFailed).toBe(false); + + await act(async () => { releaseActive(); await pending!; }); + expect(seen.current!.refreshFailed).toBe(false); +}); + +test("a cold failure still replaces the surface rather than annotating an empty one", async () => { + // Non-regression: the cold path is unchanged, and this holds it there now that a second + // failure signal exists that must not take it over. + accountsOk = false; + const seen = await mountController(); + + expect(seen.current!.loadState).toBe("error"); + expect(seen.current!.accounts).toEqual([]); +}); + +test("the roster says so on screen when the rows it shows are the pre-refresh ones", async () => { + await mountPool(makeController({ refreshFailed: true })); + + const banner = staleBanner(); + expect(banner).not.toBeNull(); + expect(banner!.textContent).toContain(en["codexAuth.accountsRefreshFailed"]); + // Non-destructive: the accounts it is qualifying are still rendered underneath it. + expect(host.textContent).toContain("main@example.test"); +}); + +test("a roster whose refresh succeeded carries no banner", async () => { + // Non-regression: passes before this change too, and is here so the new banner cannot start + // appearing over a roster the server has just confirmed. + await mountPool(makeController({ refreshFailed: false })); + + expect(staleBanner()).toBeNull(); +}); + +test("a cold failure shows its own error instead of the stale banner", async () => { + // Precedence, not regression: nothing survived to qualify, so the banner would be describing + // an empty list. The cold error has to win even though both conditions hold. + await mountPool(makeController({ accounts: [], loadState: "error", refreshFailed: true })); + + expect(staleBanner()).toBeNull(); + expect(host.textContent).toContain(en["codexAuth.loadFailed"]); +}); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index 5e64bed40a3..c730876f670 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -70,6 +70,7 @@ function makeController(overrides: Partial = {}): Co activeNeedsReauth: false, activePinnedId: null, refreshing: false, + refreshFailed: false, initialLoading: false, load: async () => true, switchAccount: async () => ({ ok: true, activeId: null }), diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 279dcc3dc35..83656758dba 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1034,6 +1034,7 @@ "oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth", "oauth-log.test.ts": "oauth", + "oauth-login-cli-browser-launch.test.ts": "oauth", "oauth-login-cli-live-update.test.ts": "oauth", "oauth-login-open-browser.test.ts": "oauth", "oauth-login-summary.test.ts": "oauth", diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index de6364eb05f..bf233f0462c 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -2,6 +2,7 @@ import { writeSync } from "node:fs"; import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; +import { BROWSER_LAUNCH_FAILED_NOTICE } from "../lib/browser-launch-notice"; import { CliUsageError, printData, @@ -80,9 +81,12 @@ interface LoginStart { * either way, so the user waits at a terminal that looks like it is working. Names the fixed * callback port because that is the part people cannot guess — ChatGPT supplies the redirect * URI, so the flow cannot move to a free port, and `--device` is the way around it. + * + * Extends the shared notice rather than repeating it: only the second line is specific to this + * flow, and the first is the sentence every other login prints for the same failure. */ export const BROWSER_LAUNCH_FAILED_HINT = - "⚠️ No browser could be opened here — open the URL above yourself." + BROWSER_LAUNCH_FAILED_NOTICE + "\n If nothing on this machine can reach http://localhost:1455, rerun with --device instead."; /** `-` means "read it from stdin", the documented way to pass a code silently. */ diff --git a/src/lib/browser-launch-notice.ts b/src/lib/browser-launch-notice.ts new file mode 100644 index 00000000000..fb39c0193e1 --- /dev/null +++ b/src/lib/browser-launch-notice.ts @@ -0,0 +1,59 @@ +import type { OpenUrlResult } from "./open-url"; + +/** + * The one sentence a terminal login says when nothing opened. + * + * Stated once because it is now said from three places — the Codex account login, the generic + * OAuth login and the key login — and a sentence restated three times drifts three ways. Callers + * that have something more specific to add append to it rather than rewriting it, so the part a + * user learns to recognize stays identical everywhere. + */ +export const BROWSER_LAUNCH_FAILED_NOTICE = + "⚠️ No browser could be opened here — open the URL above yourself."; + +/** + * Report a browser launch whose answer arrives after the code that started it has moved on. + * + * The OAuth controller does not await `onAuth`, so a CLI login cannot simply await the launcher + * there: the flow continues, and on a callback-server provider the very next thing it does is + * draw a readline prompt. A warning written at that moment lands on the line the user is typing + * on, which is worse than not warning at all. + * + * So the launch reports itself as soon as it settles, and anything that would collide with it + * waits on {@link BrowserLaunchReport.settled} first. The launcher answers within its own settle + * window, so the wait costs a fraction of a second and buys a deterministic order. + */ +export interface BrowserLaunchReport { + /** Adopt a launch already in flight. Its failure is reported once, when it settles. */ + track(launch: Promise): void; + /** + * Resolves once every launch tracked BEFORE this call has been reported, and immediately when + * none was. A login publishes one URL, so "before this call" and "at all" are the same set + * here; the narrower promise is the one this actually keeps. + */ + settled(): Promise; +} + +export function createBrowserLaunchReport( + warn: (message: string) => void = message => { console.warn(message); }, +): BrowserLaunchReport { + let pending: Promise = Promise.resolve(); + return { + track(launch) { + pending = pending + .then(() => launch) + .then( + result => { + if (result.status !== "started") warn(`\n${BROWSER_LAUNCH_FAILED_NOTICE}`); + }, + // openUrl is documented never to reject, and a launcher that did would mean the same + // thing as one that failed. Swallowing it here is not politeness: this chain is what + // `settled()` hands to a prompt, so a rejection would propagate out of a login that + // is still perfectly able to continue, and would go unhandled in the polling flows + // that do not reach that await until minutes later. + () => { warn(`\n${BROWSER_LAUNCH_FAILED_NOTICE}`); }, + ); + }, + settled: () => pending, + }; +} diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 19ad8f833cd..a842affc9ca 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -2,6 +2,7 @@ import * as readline from "node:readline"; import { modelSelectionGuidance } from "../cli/model-selection-guidance"; import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { openUrl } from "../lib/open-url"; +import { createBrowserLaunchReport } from "../lib/browser-launch-notice"; import { loadConfig, saveConfig } from "../config"; import { findLiveProxy } from "../server/proxy-liveness"; import { @@ -14,6 +15,41 @@ import type { OcxConfig, OcxProviderConfig } from "../types"; import { configuredAdminToken } from "../lib/admin-secrets"; import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; +/** + * Seams a test drives in place of a browser, a terminal and a real provider. Production passes + * none of them. + * + * They exist because the thing worth proving here is an ORDER — that a browser which did not + * open is on screen before the question that assumes it did — and an order is only observable + * from something that records both events. Spawning a launcher and attaching to stdin to find + * that out would test the operating system instead. + */ +export interface LoginCliDeps { + runLogin?: typeof runLogin; + openUrl?: typeof openUrl; + warn?: (message: string) => void; + /** Ask one question, read one line. Defaults to a readline prompt that owns its own lifetime. */ + ask?: (question: string) => Promise; +} + +/** + * Run `body` with a line reader, creating and closing a real one only when the caller did not + * supply its own. A readline interface attaches to stdin, so building one that nothing will ask + * a question keeps the process alive for no reason. + */ +async function withPrompt( + supplied: ((question: string) => Promise) | undefined, + body: (ask: (question: string) => Promise) => Promise, +): Promise { + if (supplied) return await body(supplied); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + return await body(question => new Promise(resolve => rl.question(question, resolve))); + } finally { + rl.close(); + } +} + const LIVE_RELOAD_PROVIDERS = new Set([ ...listOAuthProviders(), ...Object.keys(KEY_LOGIN_PROVIDERS), @@ -83,7 +119,7 @@ export function loginUsageMessage(): string { + ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`; } -export async function handleLogin(provider?: string): Promise { +export async function handleLogin(provider?: string, deps: LoginCliDeps = {}): Promise { const name = (provider ?? "").trim().toLowerCase(); // A removed provider id reached through its alias still logs in — the merged // successor owns the flow. Warn rather than silently reroute so scripts and @@ -91,30 +127,40 @@ export async function handleLogin(provider?: string): Promise { const alias = DEPRECATED_OAUTH_PROVIDER_ALIASES[name]; if (alias) { console.error(`${name} is deprecated; logging in as ${alias}`); - return handleOAuthLogin(alias); + return handleOAuthLogin(alias, deps); } - if (isPublicOAuthProvider(name)) return handleOAuthLogin(name); - if (isKeyLoginProvider(name)) return handleKeyLogin(name); + if (isPublicOAuthProvider(name)) return handleOAuthLogin(name, deps); + if (isKeyLoginProvider(name)) return handleKeyLogin(name, deps); console.error(loginUsageMessage()); process.exit(1); } -async function handleOAuthLogin(name: string): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - try { - await runLogin(name, { +export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}): Promise { + const login = deps.runLogin ?? runLogin; + const launch = deps.openUrl ?? openUrl; + const browser = createBrowserLaunchReport(deps.warn); + await withPrompt(deps.ask, async (ask) => { + await login(name, { onAuth: ({ url, instructions }) => { console.log(`\n🔐 Opening browser for ${name} login...\n${url}\n`); if (instructions) console.log(instructions); - void openUrl(url); + // The controller does not await onAuth, so the launcher's answer cannot be reported from + // here — this returns long before it arrives. It reports itself instead, and the one + // thing that could collide with it waits below (#5261). + browser.track(launch(url)); }, onProgress: (m) => console.log(` ${m}`), - onManualCodeInput: () => - new Promise((res) => rl.question("Paste redirect URL or code (or wait for browser): ", res)), + onManualCodeInput: async () => { + // "or wait for browser" is a lie if nothing opened, and a warning printed after readline + // has drawn the prompt lands on the line the user is typing on. + await browser.settled(); + return await ask("Paste redirect URL or code (or wait for browser): "); + }, }); - } finally { - rl.close(); - } + }); + // A device or polling provider never prompts, so nothing above waited on the launcher. It is + // still owed an answer before this claims the login worked. + await browser.settled(); const reload = await notifyRunningProxyAfterOAuthLogin(name); console.log(`\n✅ Logged in to ${name}. Try: ocx sync`); for (const line of modelSelectionGuidance(name)) console.log(line); @@ -192,8 +238,9 @@ export async function commitKeyLoginProvider( return mergedProvider; } -async function handleKeyLogin(name: string): Promise { +export async function handleKeyLogin(name: string, deps: LoginCliDeps = {}): Promise { const def = KEY_LOGIN_PROVIDERS[name]; + const launch = deps.openUrl ?? openUrl; const preflightConfig = loadConfig(); const namespaceCollision = codexAccountNamespaceProviderCollisionError(preflightConfig.codexAccountNamespaces, name); if (namespaceCollision) { @@ -201,21 +248,25 @@ async function handleKeyLogin(name: string): Promise { process.exit(1); } console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`); - void openUrl(def.dashboardUrl); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const key = (await new Promise((res) => rl.question(`Paste your ${def.label} API key: `, res))).trim(); - // Template URL with placeholders needs resolution before saving. - let baseUrl = def.baseUrl; - if (/\{[^}]*\}/.test(baseUrl)) { - const resolved = (await new Promise((res) => rl.question(`Your endpoint URL (${baseUrl}): `, res))).trim(); - if (!resolved) { - rl.close(); - console.error("A resolved URL is required — replace the {placeholder} with your actual value."); - process.exit(1); + const browser = createBrowserLaunchReport(deps.warn); + browser.track(launch(def.dashboardUrl)); + // The next question asks for a key the user gets FROM that page, so a page that never opened + // has to be on screen before the question rather than underneath it (#5261). + await browser.settled(); + const { key, baseUrl } = await withPrompt(deps.ask, async (ask) => { + const entered = (await ask(`Paste your ${def.label} API key: `)).trim(); + // Template URL with placeholders needs resolution before saving. + let resolvedBaseUrl = def.baseUrl; + if (/\{[^}]*\}/.test(resolvedBaseUrl)) { + const resolved = (await ask(`Your endpoint URL (${resolvedBaseUrl}): `)).trim(); + if (!resolved) { + console.error("A resolved URL is required — replace the {placeholder} with your actual value."); + process.exit(1); + } + resolvedBaseUrl = resolved; } - baseUrl = resolved; - } - rl.close(); + return { key: entered, baseUrl: resolvedBaseUrl }; + }); if (!key) { console.error("No key entered."); process.exit(1); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 11019881a43..7976fcd8a71 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -860,6 +860,7 @@ "oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth", "oauth-log.test.ts": "oauth", + "oauth-login-cli-browser-launch.test.ts": "oauth", "oauth-login-cli-live-update.test.ts": "oauth", "oauth-login-open-browser.test.ts": "oauth", "oauth-login-summary.test.ts": "oauth", diff --git a/tests/oauth/oauth-login-cli-browser-launch.test.ts b/tests/oauth/oauth-login-cli-browser-launch.test.ts new file mode 100644 index 00000000000..1efad4b987b --- /dev/null +++ b/tests/oauth/oauth-login-cli-browser-launch.test.ts @@ -0,0 +1,242 @@ +/** + * #5261 remainder: the two CLI logins that still discarded the launcher's answer. + * + * The landed half taught `openUrl` to say whether anything started, and the Codex account login + * to print it. `ocx login ` and `ocx login ` kept calling + * `void openUrl(...)`, so on a host with no browser both printed a URL, claimed to be opening + * it, and then asked a question that assumes it opened. The user waits at a prompt that looks + * like progress. + * + * What these assert is the ORDER, not merely the presence of a warning. The OAuth controller + * does not await `onAuth`, so the launcher answers after the login flow has moved on — on a + * callback-server provider, after it has already drawn a readline prompt. A warning that lands + * there is written over the line the user is typing on, which is why "it warns eventually" is + * not the contract. + * + * Nothing here opens a browser or attaches to stdin: both are injected, which is the only way + * two events can be observed in sequence at all. + */ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { handleKeyLogin, handleOAuthLogin, type LoginCliDeps } from "../../src/oauth/login-cli"; +import { KEY_LOGIN_PROVIDERS } from "../../src/oauth/key-providers"; +import { listOAuthProviders } from "../../src/oauth"; +import { BROWSER_LAUNCH_FAILED_NOTICE, createBrowserLaunchReport } from "../../src/lib/browser-launch-notice"; +import { BROWSER_LAUNCH_FAILED_HINT } from "../../src/cli/account-auth"; +import { repoPath } from "../helpers/repo-root"; +import { readFileSync } from "node:fs"; +import type { OpenUrlResult } from "../../src/lib/open-url"; +import type { OAuthCredentials } from "../../src/oauth/types"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let logSpy: { mockRestore(): void } | null = null; + +type FakeRunLogin = NonNullable; + +/** Named from the roster rather than typed in, so a renamed provider cannot leave this passing. */ +function anyOAuthProvider(): string { + const [first] = listOAuthProviders(); + if (!first) throw new Error("no OAuth providers are registered"); + return first; +} + +/** A key provider whose baseUrl needs no placeholder resolution, so one prompt ends the flow. */ +function anyDirectKeyProvider(): string { + const found = Object.entries(KEY_LOGIN_PROVIDERS).find(([, def]) => !/\{[^}]*\}/.test(def.baseUrl)); + if (!found) throw new Error("no key-login provider has a resolved baseUrl"); + return found[0]; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-login-launch-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-login-launch-")); + process.env.OPENCODEX_HOME = testDir; + // The one row exists so the config validates; it is deliberately not the provider under test, + // which keeps the post-login live-reload notify from looking for a proxy. An empty table would + // fail validation and be silently replaced by the packaged default, whose contents this has no + // reason to depend on. + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "login-launch-stub", + providers: { + "login-launch-stub": { + adapter: "openai-chat", + baseUrl: "https://stub.invalid/v1", + apiKey: "sk-login-launch-stub", + }, + }, + } as OcxConfig); + logSpy = spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + logSpy?.mockRestore(); + logSpy = null; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); + testDir = ""; +}); + +describe("CLI OAuth login reports the browser launch (#5261)", () => { + test("a browser that never opened is on screen before the paste prompt", async () => { + const events: string[] = []; + let settleLaunch: (result: OpenUrlResult) => void = () => {}; + const launch = new Promise(resolve => { settleLaunch = resolve; }); + const login: FakeRunLogin = async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "https://accounts.example.test/authorize?state=1" }); + // The launcher answers only after onAuth has returned. That gap is the defect: the old + // code had already discarded the promise by this point. + settleLaunch({ status: "failed", reason: "launcher-exit" }); + expect(await ctrl.onManualCodeInput?.()).toBe("pasted-code"); + return {} as OAuthCredentials; + }; + + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: login, + openUrl: () => launch, + warn: message => { events.push(`warn:${message}`); }, + ask: async () => { events.push("ask"); return "pasted-code"; }, + }); + + expect(events).toEqual([`warn:\n${BROWSER_LAUNCH_FAILED_NOTICE}`, "ask"]); + }); + + test("a browser that did open adds nothing to the prompt", async () => { + // A non-regression guard rather than proof of the fix: it passed before this change too, and + // it is here so the new warning cannot start firing on a launch that worked. + const events: string[] = []; + const login: FakeRunLogin = async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "https://accounts.example.test/authorize?state=2" }); + await ctrl.onManualCodeInput?.(); + return {} as OAuthCredentials; + }; + + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: login, + openUrl: async () => ({ status: "started" }), + warn: message => { events.push(`warn:${message}`); }, + ask: async () => { events.push("ask"); return "pasted-code"; }, + }); + + expect(events).toEqual(["ask"]); + }); + + test("a polling flow that never prompts is still told the launch failed", async () => { + // Device and polling providers publish a URL and then wait. Nothing asks a question, so + // nothing there would have waited on the launcher; the answer is still owed before the + // login claims to have worked. + const events: string[] = []; + const login: FakeRunLogin = async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "https://device.example.test/activate", deviceCode: "WDJB-MJHT" }); + ctrl.onProgress?.("Waiting for approval..."); + return {} as OAuthCredentials; + }; + + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: login, + openUrl: async () => ({ status: "failed", reason: "spawn-error" }), + warn: () => { events.push("warn"); }, + ask: async () => { events.push("ask"); return ""; }, + }); + + expect(events).toEqual(["warn"]); + }); + + test("a launcher that throws is reported, not turned into a failed login", async () => { + // openUrl documents that it never rejects, but this seam accepts any launcher. A rejection + // here used to travel out through settled(), which a polling flow does not await until the + // whole login has finished — so it would surface as an unhandled rejection and take down a + // login that could still have completed by hand. + const events: string[] = []; + const login: FakeRunLogin = async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "https://accounts.example.test/authorize?state=3" }); + ctrl.onProgress?.("Waiting for browser authentication..."); + return {} as OAuthCredentials; + }; + + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: login, + openUrl: async () => { throw new Error("launcher blew up"); }, + warn: () => { events.push("warn"); }, + ask: async () => { events.push("ask"); return ""; }, + }); + + expect(events).toEqual(["warn"]); + }); +}); + +describe("CLI key login reports the dashboard launch (#5261)", () => { + test("a dashboard that never opened is on screen before the key prompt", async () => { + const events: string[] = []; + const errors: string[] = []; + const errorSpy = spyOn(console, "error").mockImplementation((...parts: unknown[]) => { + errors.push(parts.join(" ")); + }); + const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as never); + + try { + await expect(handleKeyLogin(anyDirectKeyProvider(), { + openUrl: async () => ({ status: "failed", reason: "launcher-exit" }), + warn: () => { events.push("warn"); }, + // An empty key ends the flow immediately after the prompt. The key path beyond it is + // already covered where it lives; what this case is about is what precedes the question. + ask: async () => { events.push("ask"); return ""; }, + })).rejects.toThrow("process.exit:1"); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + + expect(events).toEqual(["warn", "ask"]); + expect(errors).toContain("No key entered."); + }); +}); + +describe("the launch report is one sentence and one order", () => { + test("settled() cannot resolve before the warning is written", async () => { + const events: string[] = []; + let settleLaunch: (result: OpenUrlResult) => void = () => {}; + const launch = new Promise(resolve => { settleLaunch = resolve; }); + const report = createBrowserLaunchReport(() => { events.push("warn"); }); + + report.track(launch); + const waited = report.settled().then(() => { events.push("prompt"); }); + settleLaunch({ status: "failed", reason: "invalid-url" }); + await waited; + + expect(events).toEqual(["warn", "prompt"]); + }); + + test("a report with nothing tracked resolves rather than hanging", async () => { + const events: string[] = []; + await createBrowserLaunchReport(() => { events.push("warn"); }).settled(); + expect(events).toEqual([]); + }); + + test("the Codex account hint extends the shared notice instead of holding its own copy", () => { + // Asserting only that the strings agree would pass on a second copy that happens to match + // today, which is the state this replaced. The source is read as well, so the sentence has + // exactly one home and a later edit to it cannot reach two thirds of the logins. + expect(BROWSER_LAUNCH_FAILED_HINT.startsWith(BROWSER_LAUNCH_FAILED_NOTICE)).toBe(true); + expect(BROWSER_LAUNCH_FAILED_HINT.length).toBeGreaterThan(BROWSER_LAUNCH_FAILED_NOTICE.length); + + const accountAuth = readFileSync(repoPath("src", "cli", "account-auth.ts"), "utf8"); + expect(accountAuth).toContain("BROWSER_LAUNCH_FAILED_NOTICE"); + expect(accountAuth).not.toContain(BROWSER_LAUNCH_FAILED_NOTICE); + }); +}); From 9a39eea40d5a927a96b1d6656319dabfb8c860e8 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:08:27 +0900 Subject: [PATCH 40/87] fix(desktop,release): sign the WidgetKit extension with the Developer ID (#5345) * fix(desktop): sign the widget extension with the Developer ID instead of ad-hoc The Tauri bundler copies macOS.files into the bundle and never signs them: copy_custom_files_to_bundle writes PlugIns/OpenCodexWidget.appex and does not add it to sign_paths, which only holds Contents/MacOS, Contents/Frameworks and the .app itself, and there is no --deep in that path. Whatever signature this script leaves is the signature that ships, and it left an ad-hoc one whenever MACOS_SIGN_IDENTITY did not reach it. macOS does not register an extension signed that way, and notarization rejects any Mach-O in the bundle without the hardened runtime. Resolve the identity before the Swift build, so a release holding Developer ID material that somehow has no identity fails in a second rather than after a universal build, and never leaves a half-built unsigned appex behind. WIDGET_SIGN_REQUIRED=1 turns the ad-hoc fallback into a refusal; the fallback stays for local builds and now carries the hardened runtime too. Sign every Mach-O the bundle actually contains, chosen by magic bytes rather than by name. Today that is one file. A suffix filter is what fails silently when it stops being one: a helper tool or an embedded dylib has no extension to match, stays unsigned, and the submission comes back "not signed with a valid Developer ID certificate" while the containing bundle looks correct. The script then re-reads its own signature and fails if the runtime flag is absent. That check captures codesign output instead of piping it, because under pipefail a matcher that exits on its first hit kills codesign with SIGPIPE and the assertion fails on exactly the signatures it was written to accept. * ci(release): make the Developer ID reachable when the widget is signed MACOS_SIGN_IDENTITY was set only on Build desktop bundles, the step after the widget is built, so Build WidgetKit extension ran with no signing environment at all and the script always took its ad-hoc branch. Pass the identity there, and require it whenever the run holds a certificate so a signed release cannot quietly ship an ad-hoc extension. codesign resolves an identity through the keychain search list and Tauri does not build its own keychain until the bundling step, so import the certificate into a temporary keychain first; Tauri re-adds itself to the same search list, so the two do not collide, and a cleanup step removes the keychain on any outcome. * test(release): bind the widget signing contract Covers the wiring that was missing rather than the code that was wrong: the widget build step carrying an identity, the certificate import preceding it, the keychain cleanup running on any outcome, the magic-byte sweep, the hardened runtime and the script's own check of it, and the refusal to ad-hoc sign a release widget. --- .github/workflows/release.yml | 8 ++ desktop/scripts/build-widget.sh | 64 +++++++++++++--- .../070_widget_signing.md | 70 +++++++++++++++++ .../release-desktop-scripts.test.ts | 75 +++++++++++++++++++ 4 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 devlog/_plan/260920_round2_followups/070_widget_signing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a10187cb5a..6aa845877ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -196,6 +196,10 @@ jobs: timeout-minutes: 45 permissions: contents: read + env: + # Whether this run holds the Developer ID material at all. A run without it still builds + # locally useful bundles; a run with it must not silently downgrade any part of the app. + DESKTOP_SIGNING_CONFIGURED: ${{ secrets.APPLE_CERTIFICATE != '' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -294,6 +298,10 @@ jobs: if: runner.os == 'macOS' env: MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + # A run holding Developer ID material must not produce an ad-hoc widget. Without this + # the script's ad-hoc branch is the silent default, which is how a signed, notarized + # app shipped with an extension macOS will not register. + WIDGET_SIGN_REQUIRED: ${{ env.DESKTOP_SIGNING_CONFIGURED == 'true' && '1' || '0' }} run: bash desktop/scripts/build-widget.sh - name: Verify the extension carries the release signature diff --git a/desktop/scripts/build-widget.sh b/desktop/scripts/build-widget.sh index fb24526e477..38e0eb9d713 100755 --- a/desktop/scripts/build-widget.sh +++ b/desktop/scripts/build-widget.sh @@ -19,6 +19,25 @@ if [[ "$universal" != "0" && "$universal" != "1" ]]; then exit 1 fi +# A widget extension is loaded by the system, not by the app, so it is validated on its own +# terms: notarization rejects any Mach-O inside it that lacks the hardened runtime, and macOS +# refuses to register an extension whose signature does not chain to the containing app's team. +# An ad-hoc signature satisfies neither, and the ad-hoc branch is the default whenever no +# identity reaches this script. Resolve that before the build so a misconfigured release fails +# in a second rather than after a universal Swift build. +if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + sign_identity="$MACOS_SIGN_IDENTITY" + timestamp_arg=(--timestamp) +elif [[ "${WIDGET_SIGN_REQUIRED:-0}" == "1" ]]; then + # A release that signs everything else and ad-hoc signs the widget produces an app that + # ships either way and simply has no widget. Refuse instead. + echo "WIDGET_SIGN_REQUIRED=1 but MACOS_SIGN_IDENTITY is empty; refusing to ad-hoc sign a release widget." >&2 + exit 1 +else + sign_identity="-" + timestamp_arg=(--timestamp=none) +fi + build_root="$(mktemp -d "${TMPDIR:-/tmp}/opencodex-widget.XXXXXX")" cleanup() { rm -rf "$build_root"; } trap cleanup EXIT @@ -65,14 +84,41 @@ version_core="${version%%-*}" plutil -replace CFBundleShortVersionString -string "$version_core" "$output_dir/Contents/Info.plist" plutil -replace CFBundleVersion -string "$version_core" "$output_dir/Contents/Info.plist" -if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then - # Hardened runtime and a secure timestamp are both required for notarized Developer ID - # software, and an extension that lacks either fails notarization with the host around it. - codesign --force --sign "$MACOS_SIGN_IDENTITY" --entitlements "$package_dir/Widget.entitlements" \ - --options runtime --timestamp "$output_dir" -else - codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ - --timestamp=none "$output_dir" -fi +# Sign inside out over every Mach-O the bundle actually contains, chosen by magic bytes rather +# than by name. Today that set is the single widget executable, but a name or extension filter +# is the thing that fails silently when it stops being true: a helper tool or an embedded +# dylib carries no suffix to match, stays unsigned, and the whole submission comes back +# "The binary is not signed with a valid Developer ID certificate" with the bundle itself +# looking perfectly signed. +mach_o_members=() +while IFS= read -r candidate; do + [[ "$(file -b "$candidate")" == *"Mach-O"* ]] || continue + mach_o_members+=("$candidate") +done < <(find "$output_dir" -type f -not -path "*/_CodeSignature/*") + +[[ ${#mach_o_members[@]} -gt 0 ]] || { echo "No Mach-O binary found in $output_dir" >&2; exit 1; } + +for member in "${mach_o_members[@]}"; do + codesign --force --sign "$sign_identity" --options runtime "${timestamp_arg[@]}" "$member" +done + +# The bundle seal goes on last and is the only signature that carries the entitlements. +codesign --force --sign "$sign_identity" --entitlements "$package_dir/Widget.entitlements" \ + --options runtime "${timestamp_arg[@]}" "$output_dir" + +codesign --verify --deep --strict "$output_dir" +# `runtime` is 0x10000 in the code directory flags. Asserting it here is what turns a silently +# unnotarizable widget into a failed build. The output is captured rather than piped into a +# matcher: `set -o pipefail` plus a matcher that exits on its first hit makes codesign die of +# SIGPIPE, and the check then fails on exactly the signatures it was meant to accept. +signature_display="$(codesign --display --verbose=4 "$output_dir" 2>&1)" +case "$signature_display" in + *"flags="*"runtime"*) ;; + *) + echo "Widget signature is missing the hardened runtime:" >&2 + echo "$signature_display" >&2 + exit 1 + ;; +esac echo "$output_dir" diff --git a/devlog/_plan/260920_round2_followups/070_widget_signing.md b/devlog/_plan/260920_round2_followups/070_widget_signing.md new file mode 100644 index 00000000000..9fbf7ec0059 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/070_widget_signing.md @@ -0,0 +1,70 @@ +# Widget extension signing + +Status: OPEN until the pull request lands on `dev`. + +The macOS app would have installed with no widget, and nothing in the build would have said so. + +## What was wrong + +`macOS.files` in `desktop/src-tauri/tauri.conf.json` puts `PlugIns/OpenCodexWidget.appex` into the +bundle. The Tauri bundler copies it and never signs it: `copy_custom_files_to_bundle` in +tauri-bundler 2.5.0 writes the file and does not add it to `sign_paths`, which only ever holds +`Contents/MacOS`, `Contents/Frameworks` and the `.app` itself. There is no `--deep` anywhere in +that path. Whatever signature `build-widget.sh` leaves is therefore the signature that ships. + +`build-widget.sh` left an ad-hoc one. Its signing branch keys on `MACOS_SIGN_IDENTITY`, and the +release workflow set that variable only on the `Build desktop bundles` step — the step *after* the +widget was built. `Build WidgetKit extension` carried no `env:` block at all, so the script always +took its `codesign --force --sign -` fallback, with `--timestamp=none` and no hardened runtime. + +macOS does not register an extension signed that way, and notarization rejects any Mach-O in a +bundle that lacks the hardened runtime. + +## What had not happened yet + +No release has shipped a macOS app. v2.58, v2.59 and v2.60 all carry zero desktop assets, and the +`APPLE_*` secrets were added to the repository hours after the last release ran. The signed branch +of this script has never executed. This is a defect found before its first victim, not one being +recovered from — the next release is where it would have landed. + +## The fix + +The script resolves its signing identity before the Swift build, so a release that holds Developer +ID material and somehow has no identity fails in a second instead of after a universal build, and +never leaves a half-built unsigned appex behind. `WIDGET_SIGN_REQUIRED=1` makes that refusal the +behaviour whenever the workflow holds a certificate; the ad-hoc branch stays for local builds. + +Signing walks every Mach-O the bundle actually contains, chosen by magic bytes rather than by name. +Today that set is one file. A suffix filter is the thing that fails silently when that stops being +true: a helper tool or an embedded dylib carries no extension to match, stays unsigned, and the +submission comes back "The binary is not signed with a valid Developer ID certificate" while the +containing bundle looks perfectly signed. Every signature now carries `--options runtime`, and the +script re-reads its own result and fails if the runtime flag is missing. + +The workflow imports the certificate into a temporary keychain before the widget is built, because +codesign resolves an identity through the keychain search list and Tauri does not build its own +keychain until the bundling step. Tauri re-adds itself to the same search list, so the two do not +collide, and a cleanup step deletes the keychain on any outcome. + +## Verification + +Run locally on macOS 27 with Xcode 27.0 and a real Developer ID in the keychain. + +- Signed path: `flags=0x10000(runtime)`, `Authority=Developer ID Application`, `TeamIdentifier` + set, secure timestamp present, `com.apple.security.app-sandbox` preserved, and + `codesign --verify --deep --strict` clean. `CFBundleShortVersionString` and `CFBundleVersion` + both resolve to the Tauri version. +- Ad-hoc path with no identity: `flags=0x10002(adhoc,runtime)` — the hardened runtime is now on + the local build too, so the two paths differ only in who signed. +- `WIDGET_SIGN_REQUIRED=1` with no identity: refuses in under a second, before the build. +- Bundle simulation: an `.app` holding the signed appex under `Contents/PlugIns`, signed the way + Tauri signs — inner executables, then the bundle, no `--deep` — keeps the nested Developer ID + signature, runtime flag, team identifier and entitlements intact, and + `codesign --verify --deep --strict` reports `--validated:...OpenCodexWidget.appex`. +- The same simulation over an appex left unsigned fails outer signing with + `In subcomponent: .../OpenCodexWidget.appex`. +- `tests/ci-workflows/release-desktop-scripts.test.ts` — 11 pass, binding the workflow wiring, the + magic-byte sweep, the hardened runtime and its self-check, and the refusal. + +End-to-end notarization of a full OpenCodex `.app` was not run; that needs a complete `tauri build` +and the release workflow is where it belongs. diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 96c9a10f03e..f008554a142 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; +import { repoPath } from "../helpers/repo-root"; function temporaryDirectory(): string { return mkdtempSync(join(tmpdir(), "opencodex-release-")); @@ -197,3 +198,77 @@ describe("desktop release scripts", () => { } }); }); + +/** + * The widget extension is the one piece of the macOS app that the Tauri bundler copies but + * never signs: `copy_custom_files_to_bundle` places `macOS.files` into the bundle and does not + * add them to `sign_paths`, so whatever signature `build-widget.sh` leaves is the signature + * that ships. That signature was ad-hoc, because the release step that builds the widget + * carried no signing environment at all while the very next step did. macOS does not register + * an extension signed that way, so the app would have installed with no widget and nothing in + * the build would have said so. + */ +describe("widget extension signing", () => { + const script = readFileSync(repoPath("desktop", "scripts", "build-widget.sh"), "utf8"); + const workflow = Bun.YAML.parse( + readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"), + ) as { + jobs?: Record; + steps?: Array<{ name?: string; if?: string; run?: string; env?: Record }>; + }>; + }; + const steps = workflow.jobs?.["package-desktop"]?.steps ?? []; + const indexOfStep = (name: string) => steps.findIndex(step => step.name === name); + + test("the release build hands the widget a signing identity and forbids an ad-hoc fallback", () => { + const build = steps.find(step => step.name === "Build WidgetKit extension"); + expect(build).toBeDefined(); + expect(build?.env?.MACOS_SIGN_IDENTITY).toContain("APPLE_SIGNING_IDENTITY"); + expect(build?.env?.WIDGET_SIGN_REQUIRED).toContain("DESKTOP_SIGNING_CONFIGURED"); + expect(workflow.jobs?.["package-desktop"]?.env?.DESKTOP_SIGNING_CONFIGURED) + .toContain("APPLE_CERTIFICATE"); + }); + + test("the certificate is importable before the widget is signed and is removed afterwards", () => { + // codesign resolves an identity through the keychain search list, and Tauri does not build + // its own keychain until the bundling step, which is after this one. + const importStep = indexOfStep("Import the Apple signing certificate for the widget"); + const buildStep = indexOfStep("Build WidgetKit extension"); + expect(importStep).toBeGreaterThanOrEqual(0); + expect(buildStep).toBeGreaterThan(importStep); + + const cleanup = steps.find(step => step.name === "Remove the widget signing keychain"); + expect(cleanup?.if).toContain("always()"); + expect(cleanup?.run).toContain("security delete-keychain"); + // The decoded p12 must not outlive the import. + expect(steps[importStep]?.run).toContain("rm -f \"$certificate\""); + }); + + test("the script selects binaries by Mach-O magic bytes rather than by name", () => { + // A suffix filter is what let an unsigned helper through on a sibling project: neither + // `spawn-helper` nor `macos-trash` has an extension to match, and the submission came back + // rejected with the containing bundle looking correctly signed. + expect(script).toContain('file -b "$candidate"'); + expect(script).toContain('*"Mach-O"*'); + expect(script).not.toMatch(/-name\s+['"]\*\.(dylib|node|so)['"]/); + }); + + test("every signature carries the hardened runtime and the build proves it afterwards", () => { + // Notarization rejects any Mach-O in the bundle without it, and the widget's was omitted. + expect(script).toContain("--options runtime"); + expect(script).toContain("codesign --verify --deep --strict"); + expect(script).toContain('*"flags="*"runtime"*)'); + // Captured, not piped: under `pipefail` a matcher that exits on its first hit kills codesign + // with SIGPIPE, and the assertion then fails on the signatures it was written to accept. + expect(script).toContain('signature_display="$(codesign --display'); + }); + + test("a run holding Developer ID material refuses to fall back to an ad-hoc widget", () => { + expect(script).toContain('elif [[ "${WIDGET_SIGN_REQUIRED:-0}" == "1" ]]; then'); + expect(script).toContain("refusing to ad-hoc sign a release widget"); + // The refusal is resolved before the Swift build so a misconfigured release fails fast. + expect(script.indexOf("refusing to ad-hoc sign a release widget")) + .toBeLessThan(script.indexOf("swift build")); + }); +}); From 07e2ac9b41894912185c82acbda6a748b698f7ed Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:08:38 +0900 Subject: [PATCH 41/87] Derive the four telemetry pull requests from the landed recorder (#5347) * feat(usage): record why a request failed, in the landed vocabulary #2366 asked for durable failure attribution and shipped its own FailureSide and seven-member FailureStage to carry it. Lane C2 took the rehydration half and left that vocabulary behind, because defining a second one beside the stage and cause model that had just landed is the class of defect that blocked 2.60.0. This is the same answer expressed in the landed vocabulary. PersistedUsageAttempt and PersistedUsageEntry now carry failureStage and failureCause, both closed roster members. The resend verdict they imply is NOT stored: it is derived at read time, so a row written by an older build can never carry a verdict the current table would no longer reach. The derivation reads only closed values -- an HTTP status, a terminal status, a close reason, a transport phase, a recovery kind. errorCode and upstreamError are deliberately excluded: both are assembled partly from upstream text, so a classification keyed on them is a different answer per provider and per locale, and a grouping key built from them cannot promise it carries no content. That exclusion is what lets the pair be a Prometheus label and a fingerprint component without a masking pass. It runs at addFinalRequestLog, the one seam every request passes exactly once whatever transport served it, and before the attempt snapshot, so the row that reaches disk and the live attempt object carry the same pair. addRequestLog rebuilds the persisted row field by field rather than spreading it, so the pair is written there explicitly -- a field omitted at that line reaches /api/logs and never reaches usage.jsonl, which is the surface the derived projection reads. The stage and cause rosters move to src/usage/telemetry-contract.ts and src/lib/request-failure-model.ts re-exports them, the same relocation lane C2 made for the recovery roster and for the same reason: the dashboard renders a label per member, and a type-only import of the table module would drag its import graph into the browser project. The decision tables stay where they were. The test runs over a cross product built from the rosters themselves rather than a written-out list, so a member added later widens the space instead of leaving a case nobody wrote. Co-authored-by: chilung * feat(metrics,gui): report the failure cause on every surface Completes the agreement condition for the attribution the previous commit records. The durable row carried a cause and nothing showed it, which is the same shape as the defect lane C2 fixed: a real cause reaching the operator as an absence of one. The exporter gains opencodex_request_failures_total{protocol,cause}. It counts the value the recorder derived rather than deriving one of its own, because the recorder is the only place that sees the transport facts a cause needs, and two derivations of one answer is exactly the disagreement this batch exists to remove. The label set IS the shared dictionary rather than a copy of it. Cardinality is fifteen causes across four protocols -- sixty series, fixed for the lifetime of the roster, every value from a frozen list -- and it labels a counter, never a histogram; a case asserts both. /api/logs computes resendPermission at read time for the row and for each attempt. It is never stored: the tables that decide it live in this build, and a row written by an older one must not assert a permission the current tables would refuse. A case asserts the pair is in the ledger module and the verdict is not. The Logs detail dialog shows the cause, the stage it reached and the resend verdict, and the attempt table leads its reason column with the cause, keeping the exact wire errorCode behind it because that is what a bug report needs. Three satisfies clauses make a missing label a typecheck failure rather than a silent fallback, and the existing catalog oracle now covers the new key groups. This trips the missing_ui_screenshot gate. This lane may not build or run the GUI, so it cannot produce the screenshot; the gate fires on changed paths under gui/, not on words in the description. The visible change is three rows added to the detail dialog for a failed request and a named cause where the attempt table previously showed a bare wire code. Co-authored-by: chilung * feat(usage): group recurring failures as a projection, not a second store #3748 proposed a privacy-safe failure ledger and built it as a second SQLite store beside usage.jsonl, keyed by a free-text signature that regular expressions tried to mask. Both halves are replaced. The store becomes a projection rebuilt from the canonical ledger. It holds a count and two timestamps per group and nothing else, so deleting a row from usage.jsonl removes it from this grouping on the next rebuild -- which is what it means for retention to have one owner instead of four. It reads through the existing scanUsageLedgerCooperatively and therefore inherits every bound that scanner already enforces: the 1 MiB row ceiling, the 1 MiB chunk, the cooperative yield, the opened-EOF snapshot boundary, and the path/device/inode/birthtime identity with its 64 KiB boundary digest. A same-size file whose revision metadata moved forces a rebuild rather than an append, so a replaced ledger can never extend stale groups. The masked signature becomes a fixed-arity tuple of closed roster members. A regular expression can only assert that it removed what it matched; a tuple whose every slot is a member of a frozen list has nothing to remove. The input type cannot express a model, an account, an error message, a prompt, a request id or a timestamp, so no amount of upstream text can reach a fingerprint. Absent facts are explicit nulls in fixed positions, because omitting them would let [a, null, b] and [a, b] collide. The configured provider name is the one input that starts as free text -- users name their own provider entries -- so it is resolved against the provider registry and becomes null when it is not a registry member. A provider named after its owner groups under null, which is the honest answer. This exposed a real hole the fingerprint would otherwise have inherited: terminalStatus was persisted as a plain string and copied through the normalizer on truthiness alone, unlike the inbound protocol, transport phase and terminal source beside it. Harmless while it was only rendered; not harmless as a grouping-key slot, because the value is assembled from an upstream terminal frame. It is now the closed type, derived from the outcome roster rather than restated, and validated on read back. Two parts of the original are deliberately absent. The occurrence list is a second copy of history with its own retention policy. The mutable monitoring/dispatched/fixed/ignored status and its notes are operator state, which cannot be reconstructed from immutable request rows; presenting them as a derived ledger would be presenting a claim this projection cannot make. They need their own owner, keyed by the fingerprint, if they are wanted. The reader is GET /api/usage?failures=1 rather than a new route: it answers a different question from the usage summary and costs a scan, so it is opt-in and a dashboard asking for spend does not pay for it. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> * feat(responses): count what an attempt delivered, on the attempt #3983 wanted the signals a stream diagnostic gives -- a missing terminal, adapter-to-client loss, empty output, partial output size -- and emitted one debug line per event to get them. Two things make that the wrong shape. It is a second durable record. emitDebugLine writes the in-process ring AND stderr, and stderr is redirected to the service log under both launchd and systemd, so an installed service accumulates a per-event history beside the ledger with its own retention, sequencing, request identity and masking. And per-event lines needed a per-payload fingerprint to correlate; under a process-global random key that makes every repeated prompt fragment, tool name and error message correlatable for the lifetime of the process. Five bounded counts on PersistedUsageAttempt answer the same questions and cannot carry content at all. They ride the attempt, so they inherit the ledger's normalization, masking and retention instead of acquiring their own, and the debug ring now FORMATS one line per finalized attempt from what the recorder already counted -- appendDebugLogLine directly, never emitDebugLine, so the ring is a live view of the durable record rather than a parallel source for it. The counting point matters. Adapter events are counted at the one seam every adapter parse already passes; relayed frames are counted after a SUCCESSFUL controller enqueue in the SSE bridge. Counting both at the reader would make the two numbers equal by construction and erase the one discrepancy they exist to expose. The recorder is bound to the request's translator budget -- an object every bridge on the delivery path already receives -- and reaches the current attempt through a callback rather than holding one, so a mid-request attempt rotation credits the attempt that is live rather than one already finalized. sideEffectEvents feeds the failure stage, which makes side-effect reachable for the first time: a relayed tool call is an externally visible effect, so the resend verdict refuses. Counting it at the transport rather than the adapter is what makes that correct -- an emitted tool call the client never received has committed nothing. Two things from the original are deliberately absent: run-turn-execution.ts is untouched, because its accounting distinguishes adapters that report their own physical sends and carrying the PR's unconditional pre-count would double-charge them; and no content HMAC exists anywhere here. Also narrows the 400 refinement added earlier in this branch, after review: it now consults only the LAST recovery recorded on the attempt, and a finalizer that can prove a cause passes it directly instead. The key-account rotation now attributes the attempt it seals, which previously reached the ledger with no attribution at all because the finalization seam only ever sees the last attempt of a request. Co-authored-by: yansigit * feat(usage): opt-in size limit for the usage ledger, with a revision contract #5063 proposed retention on the canonical ledger, which is the right architecture: the alternative is a projection that hides rows the ledger still has, and that is a second retention policy. What its implementation could not promise is that a row appended between its size snapshot and its rename survived -- it captured a size, copied a suffix, and renamed over whatever was there. Its own concurrency test performed two sequential calls and said so. Two things close that here. The append is synchronous and the compaction runs inside the same call stack, with no await between the append and the publication, so no in-process append can interleave; a second server on the same home cannot append at all, because it is refused by the existing ledger-owner lease at startup, which is why the hook is installed after ownership rather than before. And validateBeforeRename re-opens the target immediately before the rename and refuses unless identity, size and revision metadata are byte-for-byte what was copied -- so an append from anywhere else aborts the replacement rather than losing the row. Both the original file and that append survive, and the next append retries from a fresh revision. A test drives exactly that window through an injected hook, because a contract nothing can drive is a contract nobody has checked. Publication goes through the shared atomic writer rather than a hand-rolled temp lifecycle, which is where the exclusive private temp, the identity assertions, the platform-aware replace and the residual cleanup already live. The writer gains a streaming form so the retained span is copied in bounded chunks instead of held in memory as one string, and that form fsyncs the temp before the rename and does not swallow the failure: a replacement whose replacement is not on disk can lose the rows it was meant to keep. Rows are copied byte for byte and never parsed or re-serialized. A retention pass that understood the row shape would silently drop every field it was written before, which for this branch would mean the failure stage and cause it just added. The invalidation half was missing entirely from the original. Deleting rows invalidates three readers that do not watch the file: the 2,000-entry Logs ring, which otherwise keeps serving rows the ledger no longer has until eviction or a restart; the retained usage aggregate and failure projection, whose checkpoints now point past a boundary that moved; and the request-history index, whose source identity changed. All three are discarded after a replacement. This does NOT close #5063. The Usage-page control it also asks for is not here: this branch may not build or run the GUI, so it cannot produce the screenshot that gate requires, and shipping an unverifiable control is worse than shipping the policy the control would set. The limit is settable in config.json today and the docs say so. Co-authored-by: Vocllum <149675937+Vocllum@users.noreply.github.com> * fix(usage): read transport evidence before the status, and map 402 Adversarial review of this branch found three cases where the derived cause was wrong against real request paths rather than against the fabricated facts the first test used. A stream that dies mid-flight is reported as a SYNTHETIC 502 -- a tail this proxy wrote, with transportPhase mid_stream and the attempt marked aborted. Read in status order that 502 became upstream-fault, which claims the origin answered when it did not. Transport evidence now outranks the numeric status. Both causes refuse an automatic resend, so this is an accuracy fix rather than a safety one, but a label an operator cannot trust is a label they stop reading. 402 had no branch and fell through to payload-rejected, which made quota-exhausted unreachable and pointed an operator at the payload when the account is what has to change. transport-unsent was reachable only through a fabricated status 0: a real connect failure is formatted as 502 by the dispatch path. Worse, it was the FALL-THROUGH, and it is the one transport cause that permits an automatic resend. It is now reachable only through causeHint, from a site that classified a pre-connect failure and can prove it; everything else answers transport-ambiguous, which is the honest classification for an unknown execution state and the safe direction for a permission decision. Review also found the streamed atomic replacement fsynced the temp's contents and not the directory entry recording the rename, so a host losing power after a successful call could leave the old ledger or an indeterminate directory. The streaming form now syncs the parent directory. Only that form does: it is the one making a durability claim, and charging every config write for a promise its callers were never given is a different change. The regression cases now use the production shapes -- a synthetic 502 after mid_stream, an aborted stream, an upstream 502 that stays an upstream fault -- rather than a status no transport produces. * fix(usage): count buffered delivery, and read rosters instead of restating them Three findings from the second adversarial review round. A non-streaming turn delivers its whole answer as one body and calls no per-frame recorder, so every buffered response persisted adapter events with zero relayed ones. That is the adapter-to-client loss signal, raised on every buffered request, which makes the signal worthless. The buffered seam now records its delivery from the body it built: everything the adapter produced did reach the client, in one piece, and the semantic bytes and side effects are read from the assembled output. The body is read by field name rather than by the adapter event union, so a member added later is not a merge-time exhaustiveness failure in a counter that does not need one. Two tests claimed their cross products came from the declared vocabularies and then wrote the members out by hand, which is how an added member leaves an exhaustive test green without being exercised. They now read REQUEST_TERMINAL_STATUSES, REQUEST_CLOSE_REASONS and a transport-phase roster that is declared once in the contract leaf and consumed by the ledger validator instead of being stated twice. INV-RESEND-01 named two enforcing tests while the structure checker binds only the first, so the second was prose-only assurance. The attribution rule is now its own INV-ATTRIBUTION-01 with one binding, and the test names it so the binding is readable from both sides. Adds the lane record, including the two limits this branch does not close: the six intermediate attempt finalizers that still reach the ledger unattributed, and the successful-recovery case that can still misattribute a 400. It also records a pre-existing defect found while reviewing the atomic writer -- its scrub fallback opens with "wx" and so always fails on an existing temp -- which is left alone because it predates this branch and sits on a security-adjacent path. * refactor(server): move failure attribution out of request-log.ts The file-size ratchet reported NEW_OVERSIZED on the first exact-head run. src/server/request-log.ts carries the whole request-logging surface and was 1,962 lines against the repository's 2,000-line seed threshold; the attribution wiring pushed it to 2,015. The remedy is a move, never a number: the cap only ever goes down, and a threshold is not something to negotiate with. The two places a stage and cause are decided and written -- the finalization seam, and the attempt sealed by a key-account rotation -- now live in src/server/request-log-failure-attribution.ts. Behaviour is unchanged: the same facts go in, the same attempt is stamped before the snapshot, and the same pair reaches the row. request-log.ts is 1,979 lines after the move. That is 21 lines of headroom, which the lane record notes for whoever touches this file next. * fix(metrics): derive the exposition counts instead of restating them Three exact-head failures, all from the new failure-cause counter and all in assertions that counted by hand. management-metrics-export.test.ts already derives its sample total from the closed vocabularies -- its own comment says the literal "went stale the moment a bounded label value was added, which is the failure mode this repository keeps hitting in merges". The new counter's contribution is added to that arithmetic the same way. Its HELP/TYPE assertion was the literal 7 the comment warns about, so it now reads the metric names out of the exposition and asserts the two groups name the same set exactly once each, which is what deterministic grouping means and what no added metric can make stale. The dashboard-union assertion matched the literal string "import type { AttemptRecoveryKind", which broke when the import wrapped across lines to take the three new names. It now matches the property it was testing -- the name arrives from the contract leaf and the page declares no union of its own -- without depending on how the import is formatted. The public metrics table in the management-API reference gains the new series. * fix(config): assert every temp writer keeps exclusive creation, not two of them The streamed writer added a third openSync(path, "wx", 0o600) and the portability test counted exactly two. The count was the weaker form of what it meant: the property is that no temp writer in atomic-write.ts drops the O_CREAT bit, and that holds for however many writers exist. It is now a set comparison over every openSync on the temp path, which a fourth writer cannot make stale and an unsafe spelling cannot pass. The edit is line-neutral because that file sits exactly at its ratchet cap. --------- Co-authored-by: chilung Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: yansigit Co-authored-by: Vocllum <149675937+Vocllum@users.noreply.github.com> --- .../260920_round2_followups/050_lane_r5.md | 191 ++++++++++++++++ .../docs/reference/configuration/server.md | 21 ++ .../content/docs/reference/management-api.md | 1 + gui/src/i18n/de.ts | 29 +++ gui/src/i18n/en.ts | 29 +++ gui/src/i18n/fr.ts | 29 +++ gui/src/i18n/ja.ts | 29 +++ gui/src/i18n/ko.ts | 29 +++ gui/src/i18n/ru.ts | 29 +++ gui/src/i18n/tr.ts | 29 +++ gui/src/i18n/vi.ts | 29 +++ gui/src/i18n/zh-TW.ts | 29 +++ gui/src/i18n/zh.ts | 29 +++ gui/src/pages/Logs.tsx | 116 +++++++++- scripts/test-layout/layout.json | 5 + src/bridge/response-json.ts | 10 +- src/bridge/sse.ts | 10 + src/config/atomic-write.ts | 90 +++++++- src/config/schema/config-schema.ts | 9 + src/lib/debug.ts | 40 ++++ src/lib/request-failure-attribution.ts | 183 +++++++++++++++ src/lib/request-failure-model.ts | 124 +++-------- src/server/index.ts | 4 + src/server/management/logs-usage-routes.ts | 19 ++ src/server/management/shared.ts | 19 +- src/server/request-log-failure-attribution.ts | 99 +++++++++ src/server/request-log.ts | 61 +++++ src/server/request-metrics.ts | 42 +++- src/server/responses/request-transport.ts | 17 +- src/server/usage-ledger-retention.ts | 73 ++++++ src/types/config.ts | 7 + src/usage/attempt-delivery.ts | 198 +++++++++++++++++ src/usage/failure-fingerprint.ts | 118 ++++++++++ src/usage/failure-projection-cache.ts | 174 +++++++++++++++ src/usage/failure-projection.ts | 174 +++++++++++++++ src/usage/ledger-retention.ts | 160 ++++++++++++++ src/usage/log.ts | 123 ++++++++++- src/usage/request-outcome.ts | 48 ++++ src/usage/retention-contract.ts | 28 +++ src/usage/telemetry-contract.ts | 154 +++++++++++++ structure/gui-and-management-api.md | 23 +- structure/overview.md | 20 +- tests/fixtures/test-layout-expected.json | 5 + tests/lib/failure-attribution.test.ts | 209 ++++++++++++++++++ .../server/management-metrics-export.test.ts | 15 +- tests/usage/request-outcome-agreement.test.ts | 88 +++++++- tests/usage/usage-attempt-delivery.test.ts | 161 ++++++++++++++ tests/usage/usage-failure-fingerprint.test.ts | 106 +++++++++ tests/usage/usage-failure-projection.test.ts | 125 +++++++++++ tests/usage/usage-ledger-retention.test.ts | 116 ++++++++++ tests/windows/windows-secret-acl.test.ts | 8 +- 51 files changed, 3340 insertions(+), 144 deletions(-) create mode 100644 devlog/_plan/260920_round2_followups/050_lane_r5.md create mode 100644 src/lib/request-failure-attribution.ts create mode 100644 src/server/request-log-failure-attribution.ts create mode 100644 src/server/usage-ledger-retention.ts create mode 100644 src/usage/attempt-delivery.ts create mode 100644 src/usage/failure-fingerprint.ts create mode 100644 src/usage/failure-projection-cache.ts create mode 100644 src/usage/failure-projection.ts create mode 100644 src/usage/ledger-retention.ts create mode 100644 src/usage/retention-contract.ts create mode 100644 tests/lib/failure-attribution.test.ts create mode 100644 tests/usage/usage-attempt-delivery.test.ts create mode 100644 tests/usage/usage-failure-fingerprint.test.ts create mode 100644 tests/usage/usage-failure-projection.test.ts create mode 100644 tests/usage/usage-ledger-retention.test.ts diff --git a/devlog/_plan/260920_round2_followups/050_lane_r5.md b/devlog/_plan/260920_round2_followups/050_lane_r5.md new file mode 100644 index 00000000000..7343408d0cf --- /dev/null +++ b/devlog/_plan/260920_round2_followups/050_lane_r5.md @@ -0,0 +1,191 @@ +# Lane R5 — the four telemetry pull requests as derived consumers of the recorder + +Status: OPEN. Branch `codex/260920-r5-telemetry-derived`, rebased onto `dev` after `origin/dev` +advanced mid-lane. One branch, ordered commits, one pull request to `dev`. + +Lane C deferred #2366, #3748, #3983 and #5063 "as implemented", because each adds a parallel store +or a second emission path. [030_lane_c2.md](../260920_meaning_preservation_batch/030_lane_c2.md) +then specified the derived form for each. This lane builds those four forms. It adds no store: the +durable shapes stay `PersistedUsageAttempt` and `PersistedUsageEntry`, and every projection reads +them. + +## What landed, per pull request + +### #2366 (chilung-cgu) — durable failure attribution, in the landed vocabulary + +`failureStage` and `failureCause` now ride the attempt that ended a request and the logical row, +both closed roster members. `FailureSide` and the seven-member `FailureStage` are not here: two +attribution vocabularies for one question is the class that blocked 2.60.0. The PR's widening of +`transportPhase` and `terminalSource` to arbitrary strings is not here either; those validators +stay closed, and `terminalStatus` — which was a plain `string` — joined them, because it is now a +grouping-key slot and it is assembled from an upstream frame. + +The derivation reads only closed values. `errorCode` and `upstreamError` are excluded on purpose: +both carry upstream text, so a classification keyed on them is a different answer per provider and +per locale, and a key built from them cannot promise it carries no content. That exclusion is what +lets the pair be a Prometheus label and a fingerprint component with no masking pass. + +It runs at `addFinalRequestLog`, the one seam every request passes exactly once, and before the +attempt snapshot so the disk row and the live attempt carry the same pair. `addRequestLog` rebuilds +the persisted row field by field, so the pair is written there explicitly — a field omitted at that +line reaches `/api/logs` and never reaches `usage.jsonl`. + +**The resend verdict is not stored.** `/api/logs` computes `resendPermission` at read time for the +row and each attempt. The tables that decide it live in this build; a row written months ago must +not assert a permission the current tables refuse. + +**Known limit, recorded rather than hidden.** Only the attempt that ends a request, plus the one +sealed by a key-account rotation, carry attribution. The other intermediate finalizers — +`policy-fallback.ts` and five sites in `core-combo.ts` — still reach the ledger unattributed. Each +has different evidence in scope and a branch verified by static review alone should not add six new +classification call sites at once. The logical row is attributed in every case, which is what the +projection and the exporter read. + +**Second known limit.** A ciphertext or reasoning-parameter recovery that SUCCEEDED, followed by an +unrelated 400 on the same attempt, still reads as that recovery's cause. The rule is narrowed to +the last recorded kind on the matching status, and the proper fix — clearing recovery evidence on +success in `core-opaque-recovery.ts` — belongs in the recovery path, not the derivation. + +### #3748 (yansigit) — a failure grouping, not a second ledger + +`src/telemetry/` and its SQLite store are not built. Failed rows are grouped by a versioned +fingerprint over a fixed-arity tuple of closed roster members, folded during a scan of +`usage.jsonl` through the existing `scanUsageLedgerCooperatively`. The projection holds a count and +two timestamps per group; delete a ledger row and it leaves the grouping on the next rebuild. + +The free-text `signature` and its regex masking are replaced by construction rather than by a +better regex: an expression can only assert it removed what it matched, while a tuple whose every +slot comes from a frozen list has nothing to remove. Absent facts are explicit nulls in fixed +positions, because omitting them would let `[a, null, b]` and `[a, b]` collide. + +**A deliberate divergence from 030_lane_c2.md, flagged for the coordinator.** That document says +"No provider". The lane brief for R5 says the fingerprint is over "closed cause + provider + model +class". The brief is the later and more direct instruction, so `providerClass` is in the tuple — +resolved against the provider registry so it is a registry id or `null`, never the alias a user +typed. Model class is NOT in the tuple: no closed model-class vocabulary exists in this repository +and inventing one is the union-exhaustive hazard this batch exists to avoid. Removing +`providerClass` is one slot and a version bump if the coordinator prefers the C2 shape. + +The mutable `monitoring/dispatched/fixed/ignored` status and its notes are absent. They are +operator state; they cannot be reconstructed from immutable request rows, so presenting them as a +derived ledger would be a claim this projection cannot make. + +The reader is `GET /api/usage?failures=1` rather than a new route: it answers a different question +from the usage summary and costs a scan, so it is opt-in and no new CLI-parity surface appears. + +### #3983 (yansigit) — five counts on the attempt, no second emission path + +`emitDebugLine` writes the in-process ring AND stderr, and stderr is redirected to the service log +under launchd and systemd, so the PR's per-event lines would give an installed service a durable +per-event history beside the ledger. Its per-payload HMAC used a process-global random key, making +every repeated prompt fragment, tool name and error message correlatable for the process lifetime. + +Instead the attempt carries adapter events, relayed frames, semantic bytes, side effects and +terminal frames. Adapter events are counted at the existing adapter-parse seam; relayed frames +after a SUCCESSFUL `controller.enqueue`. Counting both at the reader would make them equal by +construction and erase the loss signal. The recorder is bound to the request's translator budget +and reaches the current attempt through a callback, so a mid-request attempt rotation credits the +live attempt rather than one already finalized. The debug ring now FORMATS one line per finalized +attempt from those counts, through `appendDebugLogLine` and never `emitDebugLine`. + +Adversarial review caught the case this design gets wrong on its own: a non-streaming turn delivers +one body and calls no per-frame recorder, so every buffered response would have persisted adapter +events with zero relayed ones — the loss signal, raised on every buffered request. The buffered +seam now records its delivery from the body it built. + +`run-turn-execution.ts` is untouched. Its accounting distinguishes adapters that report their own +physical sends, and the PR's unconditional pre-count would double-charge them. + +### #5063 (Vocllum) — retention with a revision contract + +`usageLedgerMaxBytes` is unset by default and unset means unlimited. When set, an append that +crosses it publishes the newest whole rows byte for byte through the shared atomic writer. + +The defect this closes: #5063 captured a size, copied a suffix and renamed over whatever was there, +so a row appended in between was silently dropped; its own concurrency test performed two +sequential calls and said it could not test concurrency. Two things close it. The append is +synchronous and the compaction runs inside the same call stack, so no in-process append can +interleave, and a second server on the same home cannot append at all — it is refused by the +existing ledger-owner lease, which is why the hook is installed after ownership. And +`validateBeforeRename` re-opens the target immediately before the rename and refuses unless +identity, size and revision metadata are byte-for-byte what was copied. A focused test drives that +exact window through an injected hook. + +Rows are copied and never parsed, which is what keeps a field a newer build wrote intact through a +compaction. The writer gained a streaming form so the retained span is not held in memory, and that +form fsyncs the temp before the rename and the parent directory after it. + +The invalidation half was missing from the original entirely. A compaction now discards the +2,000-entry Logs ring, the retained usage aggregate and failure projection, and the request-history +index — otherwise `/api/logs` keeps serving rows the ledger no longer has. + +**This does not close #5063.** The Usage-page control it also asks for is not here: this lane may +not build or run the GUI, so it cannot produce the screenshot the gate requires, and shipping an +unverifiable control is worse than shipping the policy it would set. The limit is settable in +`config.json` today and the configuration reference says so. Remaining scope: the dashboard +control, its management route, and the ten catalog strings. + +## The GUI screenshot gate + +This branch changes `gui/src/pages/Logs.tsx` and the ten locale catalogs, so `missing_ui_screenshot` +fires. It fires on changed paths under `gui/`, not on words in a description, and this lane may not +run `bun run build:gui`. A maintainer comment or the `gui-screenshot-waived` label is the documented +resolution. + +The evidence to judge it without the screenshot: the catalog edits are purely additive (+29 lines, +0 removed, in each of ten files, all exempt from the file-size ratchet), every new key exists in all +ten catalogs, and three `satisfies` clauses make a missing label a typecheck failure rather than a +silent fallback. The visible change is three rows added to the Logs detail dialog for a failed +request — the cause, the stage it reached and the resend verdict — and a named cause where the +attempt table previously led with a bare wire code. + +## Pre-existing defect found and deliberately not fixed here + +`src/config/atomic-write.ts` scrubs a failed temp through `effective.write(tmp, "")`, but the default +writer opens with `"wx"`, so that fallback always fails with `EEXIST` on an existing temp. It only +matters when `truncate` has also failed, and the temp is owner-only. It predates this branch and +affects every atomic config write, including secret-bearing ones, so fixing it is a change to a +security-adjacent path that belongs in its own lane rather than inside a telemetry branch. + +## The file-size ratchet caught this branch once + +`src/server/request-log.ts` carries the whole request-logging surface and was 1,962 lines against +the repository's 2,000-line seed threshold. The attribution wiring pushed it to 2,015, and +`file-size ratchet: repository` reported `NEW_OVERSIZED` on the first exact-head run. The remedy is +the one AGENTS.md gives — a move, never a number — so the two places a stage and cause are decided +and written moved to `src/server/request-log-failure-attribution.ts`, leaving the file at 1,979. + +Worth recording for the next lane that touches this file: 21 lines of headroom is not much, and +the cap only ever moves down. + +## Verification + +Static source review plus exact-head hosted CI, and three adversarial reviews at high effort +covering typecheck hazards, repository gates, and runtime correctness and privacy. Their findings +are in the branch: the transport-evidence precedence, the 402 mapping, `transport-unsent` no longer +being the fall-through, the parent-directory fsync, the buffered delivery accounting, the rosters +read instead of restated in two tests, and the invariant split into INV-RESEND-01 and +INV-ATTRIBUTION-01 so each binds exactly one test. + +NOT RUN on this branch, by instruction: `bun run test`, any individual `bun test` file, +`bun run typecheck`, `bun run build:gui`, `bun run lint:gui`, `bun install`, +`bun run structure:check`, `bun run privacy:scan`, and any live `ocx` execution. None of these may +be recorded as passing. + +Checked statically: + +- no file this branch touches is at or over its file-size ratchet cap; `src/server/index.ts` sits at + 884 against 893, and the ten catalogs are exempt; +- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` agree key for key, + and each new test's regex seed resolves to the domain it is registered to — `failure-attribution` + is named to avoid the `request-` seed that would have placed it in `usage`; +- `src/usage/telemetry-contract.ts` still has no imports, `src/usage/request-outcome.ts` still reaches + nothing but it, and `gui/src/pages/Logs.tsx` still never names `src/usage/log`; +- no test or document restates a source constant: the rosters, the fingerprint version and the label + keys are read from the modules that declare them. + +## Issues + +#2366, #3748 and #3983 are addressed by these derived forms; the coordinator decides closure. #5063 +is partially addressed and must not be closed — its dashboard control is named above as remaining +scope. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 19b3bb2f002..78df10f7513 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -26,6 +26,7 @@ runs helper features around provider requests. | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://` are supported; `*` is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Generated `ocx_…` data-plane admission credentials on non-loopback binds. They do not authorize management APIs; management access uses the separate credential documented in the [management reference](/reference/management-api/). Dashboard-managed. | | `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | Opt-in archived-session cleanup policy. Never enabled implicitly. | +| `usageLedgerMaxBytes?` | `number` | unset | Opt-in ceiling in bytes for `usage.jsonl`. Absent means the request history grows without limit, which stays the default. See [usage history size](#usage-history-size). | | `appOwnedMemoryBudgetMb?` | `number` | `256` | Cap in MiB for evictable app-owned logs, caches, blobs, and continuation payloads. Range 64–4096; not an RSS cap. | | `metricsExport.enabled?` | `boolean` | `false` | Enable process-local aggregate request metrics at authenticated `GET /api/metrics`. Restart required; disabled mode returns 404 and starts no exporter activity. | | `spend?` | `{ root?: { maxTokens?: number }; identity?: { maxTokens?: number }; pool?: { maxTokens?: number }; retentionDays?: number }` | unset | Durable token ceilings, off unless you write one. Each scope bounds settled spend plus in-flight reservations plus unresolved spend: `root` is one task including its whole fan-out, `identity` is one account across every task it serves, and `pool` is one provider pool. They intersect, so a request is admitted only when all three have room — which is what holds a ceiling against a client that mints a new task id per request. A reservation is the request's whole input plus its enforceable output ceiling, counted as if every cached prefix misses. Observe-only mode still journals, so every server owns the state directory's single-writer lease; an explicit sibling must use a separate `OPENCODEX_HOME`. Spend survives an ordinary process restart when its writes reached the filesystem, but the journal does not promise survival across host power loss because each append is not fsynced. Raising or removing the value is what grants more. `maxTokens` must be a positive integer (0 would refuse everything), `retentionDays` is 1–365 and defaults to 7, and an unknown key in this section is rejected rather than ignored. A refusal is a local HTTP 429 carrying `x-opencodex-local-refusal: workflow_spend_exhausted`, and its message names the scope and the ceiling; no provider is contacted. | @@ -381,6 +382,26 @@ either `target.reduceToBytes` or `target.removeOldestPercent`. `mode` defaults t Configure it on the Storage page or with `GET`/`PUT /api/storage/cleanup-policy`; trigger a manual run with `POST /api/storage/cleanup-policy/run`. +## Usage history size + +`usageLedgerMaxBytes` is unset by default, and unset means the request history in `usage.jsonl` +grows without limit. Nothing deletes history you did not ask to have deleted. + +Set it to a byte ceiling and the proxy trims the file after an append crosses it, keeping the +newest whole rows and dropping the oldest. It trims a little below the ceiling rather than exactly +to it, so the next append does not immediately re-cross the line. The minimum accepted value is +1 MiB; a smaller number, or one that is not a safe integer, leaves the limit off rather than +failing the configuration. + +Rows are copied byte for byte and never rewritten, so every field survives a trim — including +fields a newer build wrote that an older one does not understand. The replacement is refused +outright if anything appended to the ledger while it ran, so a request logged during a trim is +never lost; the next append tries again. Trimming also refreshes what the dashboard shows, so +`/api/logs` stops serving rows the ledger no longer has. + +There is no dashboard control for this yet; set it in `config.json` or with +`ocx config set usageLedgerMaxBytes `. + ## Quota-reset notifications (`quotaResetNotify`) Off by default. When the section is absent, no detection runs, no timer starts, and no state diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 6dbf08fd9d8..9a91de21711 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -285,6 +285,7 @@ boundary. Histogram buckets are cumulative and end with `le="+Inf"`, equal to th | `opencodex_logical_requests_total` | `protocol`, `result` | One observation per finalized logical request. | | `opencodex_physical_sends_total` | `protocol` | Actual upstream sends summed from finalized attempts. | | `opencodex_recoveries_total` | `protocol`, `recovery` | Distinct recovery kinds observed per attempt, projected to a closed class. | +| `opencodex_request_failures_total` | `protocol`, `cause` | Finalized requests that did not deliver an answer, by the cause the recorder derived. Counter only; no histogram carries a cause. | | `opencodex_request_duration_seconds` | `protocol`, `result` | Fixed-bucket duration histogram for finalized requests. | | `opencodex_ttft_seconds` | `protocol`, `result` | Fixed-bucket TTFT histogram for requests with observed first output. | | `opencodex_ttft_missing_total` | `protocol`, `result` | Complementary count for requests without observed TTFT. | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d6c3f47ddac..2ae1b8c57f4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -885,6 +885,35 @@ export const de: Record = { "logs.detail.sends.label": "Upstream-Sendungen", "logs.detail.sends.unresolved": "ungeklärt", "logs.detail.attempt.recovery.unknown": "Unbekannter Wiederherstellungsgrund", + "logs.detail.cause.label": "Fehlerursache", + "logs.detail.stage.label": "erreicht", + "logs.detail.resend.label": "Erneut senden", + "logs.detail.cause.transportUnsent": "Nie an Upstream gesendet", + "logs.detail.cause.transportAmbiguous": "Verbindung verloren, Upstream-Status unbekannt", + "logs.detail.cause.upstreamDeclined": "Upstream lehnte den Start ab", + "logs.detail.cause.rateLimit": "Ratenlimit erreicht", + "logs.detail.cause.quotaExhausted": "Kontingent aufgebraucht", + "logs.detail.cause.credentialRejected": "Anmeldedaten abgelehnt", + "logs.detail.cause.policyRefusal": "Von der Inhaltsrichtlinie abgelehnt", + "logs.detail.cause.parameterRejected": "Anfrageparameter abgelehnt", + "logs.detail.cause.ciphertextRefusal": "Verschlüsselter Zustand abgelehnt", + "logs.detail.cause.payloadTooLarge": "Nutzlast zu groß", + "logs.detail.cause.payloadRejected": "Nutzlast abgelehnt", + "logs.detail.cause.upstreamFault": "Upstream-Fehler", + "logs.detail.cause.emptyOutput": "Keine verwertbare Ausgabe", + "logs.detail.cause.clientCancelled": "Vom Client abgebrochen", + "logs.detail.cause.localRefusal": "Von diesem Proxy abgelehnt", + "logs.detail.stage.preHeader": "kein Antwortkopf", + "logs.detail.stage.headersOnly": "nur Kopfzeilen", + "logs.detail.stage.protocolPrelude": "Protokollvorspann", + "logs.detail.stage.semanticOutput": "Ausgabe zugestellt", + "logs.detail.stage.sideEffect": "Nebenwirkung ausgelöst", + "logs.detail.stage.terminal": "Antwort zugestellt", + "logs.detail.resend.permitted": "Erneut sendbar", + "logs.detail.resend.permittedAfterRepair": "Nach Korrektur erneut sendbar", + "logs.detail.resend.refusedAmbiguous": "Nicht erneut gesendet: Upstream-Status unbekannt", + "logs.detail.resend.refusedCommitted": "Nicht erneut gesendet: Aufrufer sah bereits Ausgabe", + "logs.detail.resend.refusedFutile": "Nicht erneut gesendet: dieselbe Anfrage scheitert erneut", "logs.detail.reason.usage_missing": "Nutzung wurde nicht gemeldet.", "logs.detail.reason.usage_unsupported": "Dieser Anbieter meldet keine Nutzung.", "logs.detail.reason.output_missing": "Es wurden keine positiven Ausgabe-Tokens gemeldet.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e3ac88d19df..a2369042f96 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -934,6 +934,35 @@ export const en = { "logs.detail.sends.label": "Upstream sends", "logs.detail.sends.unresolved": "unresolved", "logs.detail.attempt.recovery.unknown": "Unknown recovery reason", + "logs.detail.cause.label": "Failure cause", + "logs.detail.stage.label": "reached", + "logs.detail.resend.label": "Resend", + "logs.detail.cause.transportUnsent": "Never sent upstream", + "logs.detail.cause.transportAmbiguous": "Connection lost, upstream state unknown", + "logs.detail.cause.upstreamDeclined": "Upstream declined to start", + "logs.detail.cause.rateLimit": "Rate limited", + "logs.detail.cause.quotaExhausted": "Quota exhausted", + "logs.detail.cause.credentialRejected": "Credentials rejected", + "logs.detail.cause.policyRefusal": "Refused by content policy", + "logs.detail.cause.parameterRejected": "Request parameter rejected", + "logs.detail.cause.ciphertextRefusal": "Encrypted state rejected", + "logs.detail.cause.payloadTooLarge": "Payload too large", + "logs.detail.cause.payloadRejected": "Payload rejected", + "logs.detail.cause.upstreamFault": "Upstream fault", + "logs.detail.cause.emptyOutput": "No usable output", + "logs.detail.cause.clientCancelled": "Cancelled by the client", + "logs.detail.cause.localRefusal": "Refused by this proxy", + "logs.detail.stage.preHeader": "no response head", + "logs.detail.stage.headersOnly": "headers only", + "logs.detail.stage.protocolPrelude": "protocol prelude", + "logs.detail.stage.semanticOutput": "output delivered", + "logs.detail.stage.sideEffect": "side effect emitted", + "logs.detail.stage.terminal": "answer delivered", + "logs.detail.resend.permitted": "Could be resent", + "logs.detail.resend.permittedAfterRepair": "Could be resent after repair", + "logs.detail.resend.refusedAmbiguous": "Not resent: upstream state unknown", + "logs.detail.resend.refusedCommitted": "Not resent: caller already saw output", + "logs.detail.resend.refusedFutile": "Not resent: the same request would fail again", "logs.detail.reason.usage_missing": "Usage was not reported.", "logs.detail.reason.usage_unsupported": "This provider does not report usage.", "logs.detail.reason.output_missing": "No positive output token count was reported.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5c166a81795..977af5f5e6a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -915,6 +915,35 @@ export const fr: Record = { "logs.detail.attempt.recovery.anthropicOauth429": "Limitation de débit OAuth Anthropic (429)", "logs.detail.attempt.recovery.image413": "Charge utile d’image trop volumineuse (413)", "logs.detail.attempt.recovery.unknown": "Motif de récupération inconnu", + "logs.detail.cause.label": "Cause de l'échec", + "logs.detail.stage.label": "atteint", + "logs.detail.resend.label": "Renvoi", + "logs.detail.cause.transportUnsent": "Jamais envoyé en amont", + "logs.detail.cause.transportAmbiguous": "Connexion perdue, état amont inconnu", + "logs.detail.cause.upstreamDeclined": "L'amont a refusé de démarrer", + "logs.detail.cause.rateLimit": "Débit limité", + "logs.detail.cause.quotaExhausted": "Quota épuisé", + "logs.detail.cause.credentialRejected": "Identifiants refusés", + "logs.detail.cause.policyRefusal": "Refusé par la politique de contenu", + "logs.detail.cause.parameterRejected": "Paramètre de requête refusé", + "logs.detail.cause.ciphertextRefusal": "État chiffré refusé", + "logs.detail.cause.payloadTooLarge": "Charge utile trop volumineuse", + "logs.detail.cause.payloadRejected": "Charge utile refusée", + "logs.detail.cause.upstreamFault": "Panne en amont", + "logs.detail.cause.emptyOutput": "Aucune sortie exploitable", + "logs.detail.cause.clientCancelled": "Annulé par le client", + "logs.detail.cause.localRefusal": "Refusé par ce proxy", + "logs.detail.stage.preHeader": "aucun en-tête de réponse", + "logs.detail.stage.headersOnly": "en-têtes seulement", + "logs.detail.stage.protocolPrelude": "prélude de protocole", + "logs.detail.stage.semanticOutput": "sortie livrée", + "logs.detail.stage.sideEffect": "effet de bord émis", + "logs.detail.stage.terminal": "réponse livrée", + "logs.detail.resend.permitted": "Peut être renvoyé", + "logs.detail.resend.permittedAfterRepair": "Peut être renvoyé après correction", + "logs.detail.resend.refusedAmbiguous": "Non renvoyé : état amont inconnu", + "logs.detail.resend.refusedCommitted": "Non renvoyé : l'appelant a déjà vu la sortie", + "logs.detail.resend.refusedFutile": "Non renvoyé : la même requête échouerait encore", "logs.detail.reason.usage_missing": "L’utilisation n’a pas été communiquée.", "logs.detail.reason.usage_unsupported": "Ce fournisseur ne communique pas l’utilisation.", "logs.detail.reason.output_missing": "Aucun nombre positif de jetons de sortie n’a été communiqué.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index adc3439a347..23bd87d4876 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -846,6 +846,35 @@ export const ja: Record = { "logs.detail.sends.label": "アップストリーム送信", "logs.detail.sends.unresolved": "未確定", "logs.detail.attempt.recovery.unknown": "不明なリカバリ理由", + "logs.detail.cause.label": "失敗の原因", + "logs.detail.stage.label": "到達段階", + "logs.detail.resend.label": "再送", + "logs.detail.cause.transportUnsent": "アップストリームへ未送信", + "logs.detail.cause.transportAmbiguous": "接続が切れ、処理状況は不明", + "logs.detail.cause.upstreamDeclined": "アップストリームが開始を拒否", + "logs.detail.cause.rateLimit": "レート制限", + "logs.detail.cause.quotaExhausted": "クォータ枯渇", + "logs.detail.cause.credentialRejected": "認証情報が拒否されました", + "logs.detail.cause.policyRefusal": "コンテンツポリシーによる拒否", + "logs.detail.cause.parameterRejected": "リクエストパラメータが拒否されました", + "logs.detail.cause.ciphertextRefusal": "暗号化状態が拒否されました", + "logs.detail.cause.payloadTooLarge": "ペイロードが大きすぎます", + "logs.detail.cause.payloadRejected": "ペイロードが拒否されました", + "logs.detail.cause.upstreamFault": "アップストリーム障害", + "logs.detail.cause.emptyOutput": "利用可能な出力なし", + "logs.detail.cause.clientCancelled": "クライアントがキャンセル", + "logs.detail.cause.localRefusal": "このプロキシが拒否", + "logs.detail.stage.preHeader": "レスポンスヘッダーなし", + "logs.detail.stage.headersOnly": "ヘッダーのみ", + "logs.detail.stage.protocolPrelude": "プロトコル前段", + "logs.detail.stage.semanticOutput": "出力を配信済み", + "logs.detail.stage.sideEffect": "副作用が発生", + "logs.detail.stage.terminal": "応答を配信済み", + "logs.detail.resend.permitted": "再送可能", + "logs.detail.resend.permittedAfterRepair": "修正後に再送可能", + "logs.detail.resend.refusedAmbiguous": "再送なし: アップストリームの状態が不明", + "logs.detail.resend.refusedCommitted": "再送なし: 呼び出し元が既に出力を受信", + "logs.detail.resend.refusedFutile": "再送なし: 同じ要求は再び失敗します", "logs.detail.reason.usage_missing": "使用量が報告されませんでした。", "logs.detail.reason.usage_unsupported": "このプロバイダーは使用量を報告しません。", "logs.detail.reason.output_missing": "正の出力トークン数が報告されませんでした。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 2e56314db9c..e6dbe127125 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -916,6 +916,35 @@ export const ko: Record = { "logs.detail.sends.label": "업스트림 전송", "logs.detail.sends.unresolved": "미확인", "logs.detail.attempt.recovery.unknown": "알 수 없는 복구 사유", + "logs.detail.cause.label": "실패 원인", + "logs.detail.stage.label": "도달 지점", + "logs.detail.resend.label": "재전송", + "logs.detail.cause.transportUnsent": "업스트림으로 전송되지 않음", + "logs.detail.cause.transportAmbiguous": "연결이 끊겨 업스트림 처리 여부 불명", + "logs.detail.cause.upstreamDeclined": "업스트림이 시작을 거부함", + "logs.detail.cause.rateLimit": "요청 한도 초과", + "logs.detail.cause.quotaExhausted": "할당량 소진", + "logs.detail.cause.credentialRejected": "자격 증명 거부됨", + "logs.detail.cause.policyRefusal": "콘텐츠 정책으로 거부됨", + "logs.detail.cause.parameterRejected": "요청 파라미터 거부됨", + "logs.detail.cause.ciphertextRefusal": "암호화된 상태 거부됨", + "logs.detail.cause.payloadTooLarge": "페이로드가 너무 큼", + "logs.detail.cause.payloadRejected": "페이로드 거부됨", + "logs.detail.cause.upstreamFault": "업스트림 장애", + "logs.detail.cause.emptyOutput": "사용할 수 있는 출력 없음", + "logs.detail.cause.clientCancelled": "클라이언트가 취소함", + "logs.detail.cause.localRefusal": "이 프록시가 거부함", + "logs.detail.stage.preHeader": "응답 헤더 없음", + "logs.detail.stage.headersOnly": "헤더까지", + "logs.detail.stage.protocolPrelude": "프로토콜 프리앰블", + "logs.detail.stage.semanticOutput": "출력 전달됨", + "logs.detail.stage.sideEffect": "부수 효과 발생", + "logs.detail.stage.terminal": "응답 전달 완료", + "logs.detail.resend.permitted": "재전송 가능", + "logs.detail.resend.permittedAfterRepair": "수정 후 재전송 가능", + "logs.detail.resend.refusedAmbiguous": "재전송 안 함: 업스트림 처리 여부 불명", + "logs.detail.resend.refusedCommitted": "재전송 안 함: 이미 출력이 전달됨", + "logs.detail.resend.refusedFutile": "재전송 안 함: 같은 요청은 다시 실패함", "logs.detail.reason.usage_missing": "usage가 보고되지 않았습니다.", "logs.detail.reason.usage_unsupported": "이 프로바이더는 usage 보고를 지원하지 않습니다.", "logs.detail.reason.output_missing": "양수 출력 토큰 수가 보고되지 않았습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ee734f14fe8..11a6c3d0011 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -902,6 +902,35 @@ export const ru: Record = { "logs.detail.sends.label": "Отправки к провайдеру", "logs.detail.sends.unresolved": "не подтверждено", "logs.detail.attempt.recovery.unknown": "Неизвестная причина восстановления", + "logs.detail.cause.label": "Причина сбоя", + "logs.detail.stage.label": "достигнуто", + "logs.detail.resend.label": "Повторная отправка", + "logs.detail.cause.transportUnsent": "Не отправлено на сервер", + "logs.detail.cause.transportAmbiguous": "Соединение потеряно, состояние сервера неизвестно", + "logs.detail.cause.upstreamDeclined": "Сервер отказался начать", + "logs.detail.cause.rateLimit": "Превышен лимит запросов", + "logs.detail.cause.quotaExhausted": "Квота исчерпана", + "logs.detail.cause.credentialRejected": "Учётные данные отклонены", + "logs.detail.cause.policyRefusal": "Отклонено политикой контента", + "logs.detail.cause.parameterRejected": "Параметр запроса отклонён", + "logs.detail.cause.ciphertextRefusal": "Зашифрованное состояние отклонено", + "logs.detail.cause.payloadTooLarge": "Слишком большой запрос", + "logs.detail.cause.payloadRejected": "Тело запроса отклонено", + "logs.detail.cause.upstreamFault": "Сбой на стороне сервера", + "logs.detail.cause.emptyOutput": "Нет пригодного вывода", + "logs.detail.cause.clientCancelled": "Отменено клиентом", + "logs.detail.cause.localRefusal": "Отклонено этим прокси", + "logs.detail.stage.preHeader": "нет заголовка ответа", + "logs.detail.stage.headersOnly": "только заголовки", + "logs.detail.stage.protocolPrelude": "начало протокола", + "logs.detail.stage.semanticOutput": "вывод доставлен", + "logs.detail.stage.sideEffect": "побочный эффект отправлен", + "logs.detail.stage.terminal": "ответ доставлен", + "logs.detail.resend.permitted": "Можно отправить повторно", + "logs.detail.resend.permittedAfterRepair": "Можно отправить повторно после исправления", + "logs.detail.resend.refusedAmbiguous": "Не отправлено повторно: состояние сервера неизвестно", + "logs.detail.resend.refusedCommitted": "Не отправлено повторно: клиент уже получил вывод", + "logs.detail.resend.refusedFutile": "Не отправлено повторно: тот же запрос снова не пройдёт", "logs.detail.reason.usage_missing": "Данные об использовании не были сообщены.", "logs.detail.reason.usage_unsupported": "Этот провайдер не сообщает данные об использовании.", "logs.detail.reason.output_missing": "Положительное число выходных токенов не было сообщено.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 91209cc436a..5d425389fa1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -921,6 +921,35 @@ export const tr: Record = { "logs.detail.sends.label": "Yukarı akış gönderimleri", "logs.detail.sends.unresolved": "çözülmemiş", "logs.detail.attempt.recovery.unknown": "Bilinmeyen kurtarma nedeni", + "logs.detail.cause.label": "Hata nedeni", + "logs.detail.stage.label": "ulaşıldı", + "logs.detail.resend.label": "Yeniden gönder", + "logs.detail.cause.transportUnsent": "Sunucuya hiç gönderilmedi", + "logs.detail.cause.transportAmbiguous": "Bağlantı koptu, sunucu durumu bilinmiyor", + "logs.detail.cause.upstreamDeclined": "Sunucu başlamayı reddetti", + "logs.detail.cause.rateLimit": "Hız sınırına takıldı", + "logs.detail.cause.quotaExhausted": "Kota tükendi", + "logs.detail.cause.credentialRejected": "Kimlik bilgileri reddedildi", + "logs.detail.cause.policyRefusal": "İçerik politikası reddetti", + "logs.detail.cause.parameterRejected": "İstek parametresi reddedildi", + "logs.detail.cause.ciphertextRefusal": "Şifreli durum reddedildi", + "logs.detail.cause.payloadTooLarge": "Yük çok büyük", + "logs.detail.cause.payloadRejected": "Yük reddedildi", + "logs.detail.cause.upstreamFault": "Sunucu arızası", + "logs.detail.cause.emptyOutput": "Kullanılabilir çıktı yok", + "logs.detail.cause.clientCancelled": "İstemci iptal etti", + "logs.detail.cause.localRefusal": "Bu proxy reddetti", + "logs.detail.stage.preHeader": "yanıt başlığı yok", + "logs.detail.stage.headersOnly": "yalnızca başlıklar", + "logs.detail.stage.protocolPrelude": "protokol ön bölümü", + "logs.detail.stage.semanticOutput": "çıktı iletildi", + "logs.detail.stage.sideEffect": "yan etki üretildi", + "logs.detail.stage.terminal": "yanıt iletildi", + "logs.detail.resend.permitted": "Yeniden gönderilebilir", + "logs.detail.resend.permittedAfterRepair": "Onarımdan sonra yeniden gönderilebilir", + "logs.detail.resend.refusedAmbiguous": "Yeniden gönderilmedi: sunucu durumu bilinmiyor", + "logs.detail.resend.refusedCommitted": "Yeniden gönderilmedi: çağıran çıktıyı zaten gördü", + "logs.detail.resend.refusedFutile": "Yeniden gönderilmedi: aynı istek yine başarısız olur", "logs.detail.reason.usage_missing": "Kullanım bildirilmedi.", "logs.detail.reason.usage_unsupported": "Bu sağlayıcı kullanım bildirmeyebilir.", "logs.detail.reason.output_missing": "Çıktı jeton sayısı bildirilmedi.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 45d48b839aa..88c8fc92fa4 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -908,6 +908,35 @@ export const vi: Record = { "logs.detail.sends.label": "Số lần gửi lên nhà cung cấp", "logs.detail.sends.unresolved": "chưa xác định", "logs.detail.attempt.recovery.unknown": "Lý do khôi phục không rõ", + "logs.detail.cause.label": "Nguyên nhân lỗi", + "logs.detail.stage.label": "đã đến", + "logs.detail.resend.label": "Gửi lại", + "logs.detail.cause.transportUnsent": "Chưa gửi lên thượng nguồn", + "logs.detail.cause.transportAmbiguous": "Mất kết nối, không rõ trạng thái thượng nguồn", + "logs.detail.cause.upstreamDeclined": "Thượng nguồn từ chối bắt đầu", + "logs.detail.cause.rateLimit": "Bị giới hạn tốc độ", + "logs.detail.cause.quotaExhausted": "Đã hết hạn mức", + "logs.detail.cause.credentialRejected": "Thông tin xác thực bị từ chối", + "logs.detail.cause.policyRefusal": "Bị chính sách nội dung từ chối", + "logs.detail.cause.parameterRejected": "Tham số yêu cầu bị từ chối", + "logs.detail.cause.ciphertextRefusal": "Trạng thái mã hoá bị từ chối", + "logs.detail.cause.payloadTooLarge": "Tải trọng quá lớn", + "logs.detail.cause.payloadRejected": "Tải trọng bị từ chối", + "logs.detail.cause.upstreamFault": "Lỗi thượng nguồn", + "logs.detail.cause.emptyOutput": "Không có đầu ra dùng được", + "logs.detail.cause.clientCancelled": "Ứng dụng khách đã huỷ", + "logs.detail.cause.localRefusal": "Proxy này từ chối", + "logs.detail.stage.preHeader": "không có đầu phản hồi", + "logs.detail.stage.headersOnly": "chỉ có tiêu đề", + "logs.detail.stage.protocolPrelude": "phần mở đầu giao thức", + "logs.detail.stage.semanticOutput": "đã gửi đầu ra", + "logs.detail.stage.sideEffect": "đã phát sinh tác dụng phụ", + "logs.detail.stage.terminal": "đã gửi câu trả lời", + "logs.detail.resend.permitted": "Có thể gửi lại", + "logs.detail.resend.permittedAfterRepair": "Có thể gửi lại sau khi sửa", + "logs.detail.resend.refusedAmbiguous": "Không gửi lại: không rõ trạng thái thượng nguồn", + "logs.detail.resend.refusedCommitted": "Không gửi lại: bên gọi đã thấy đầu ra", + "logs.detail.resend.refusedFutile": "Không gửi lại: yêu cầu tương tự vẫn sẽ lỗi", "logs.detail.reason.usage_missing": "Mức sử dụng không được báo cáo.", "logs.detail.reason.usage_unsupported": "Provider này không báo cáo mức sử dụng.", "logs.detail.reason.output_missing": "Không có số lượng output token dương nào được báo cáo.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e1cf2ca48e5..109f4378918 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2410,6 +2410,35 @@ export const zhTW: Record = { "logs.detail.sends.label": "上游傳送次數", "logs.detail.sends.unresolved": "未結算", "logs.detail.attempt.recovery.unknown": "未知的復原原因", + "logs.detail.cause.label": "失敗原因", + "logs.detail.stage.label": "到達階段", + "logs.detail.resend.label": "重送", + "logs.detail.cause.transportUnsent": "未傳送至上游", + "logs.detail.cause.transportAmbiguous": "連線中斷,上游狀態不明", + "logs.detail.cause.upstreamDeclined": "上游拒絕開始", + "logs.detail.cause.rateLimit": "速率受限", + "logs.detail.cause.quotaExhausted": "配額耗盡", + "logs.detail.cause.credentialRejected": "憑證遭拒", + "logs.detail.cause.policyRefusal": "遭內容政策拒絕", + "logs.detail.cause.parameterRejected": "請求參數遭拒", + "logs.detail.cause.ciphertextRefusal": "加密狀態遭拒", + "logs.detail.cause.payloadTooLarge": "酬載過大", + "logs.detail.cause.payloadRejected": "酬載遭拒", + "logs.detail.cause.upstreamFault": "上游故障", + "logs.detail.cause.emptyOutput": "沒有可用輸出", + "logs.detail.cause.clientCancelled": "用戶端已取消", + "logs.detail.cause.localRefusal": "遭此代理拒絕", + "logs.detail.stage.preHeader": "無回應標頭", + "logs.detail.stage.headersOnly": "僅標頭", + "logs.detail.stage.protocolPrelude": "協定前導", + "logs.detail.stage.semanticOutput": "已傳遞輸出", + "logs.detail.stage.sideEffect": "已產生副作用", + "logs.detail.stage.terminal": "已傳遞答覆", + "logs.detail.resend.permitted": "可以重送", + "logs.detail.resend.permittedAfterRepair": "修復後可以重送", + "logs.detail.resend.refusedAmbiguous": "未重送:上游狀態不明", + "logs.detail.resend.refusedCommitted": "未重送:呼叫端已收到輸出", + "logs.detail.resend.refusedFutile": "未重送:相同請求仍會失敗", "logs.detail.estimate.provider_cost_overlay": "已使用供應商設定的價格覆蓋。", "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 Priority 價格;目前顯示的估算是已知下限。", "pws.cockpitImportDescription": "從此裝置匯入 Cockpit Tools Antigravity JSON 匯出檔。不會顯示檔案內容。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c9dbe680345..b550c7eb086 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -897,6 +897,35 @@ export const zh: Record = { "logs.detail.sends.label": "上游发送次数", "logs.detail.sends.unresolved": "未结算", "logs.detail.attempt.recovery.unknown": "未知的恢复原因", + "logs.detail.cause.label": "失败原因", + "logs.detail.stage.label": "到达阶段", + "logs.detail.resend.label": "重发", + "logs.detail.cause.transportUnsent": "未发送到上游", + "logs.detail.cause.transportAmbiguous": "连接中断,上游状态未知", + "logs.detail.cause.upstreamDeclined": "上游拒绝开始", + "logs.detail.cause.rateLimit": "速率受限", + "logs.detail.cause.quotaExhausted": "配额耗尽", + "logs.detail.cause.credentialRejected": "凭据被拒绝", + "logs.detail.cause.policyRefusal": "被内容策略拒绝", + "logs.detail.cause.parameterRejected": "请求参数被拒绝", + "logs.detail.cause.ciphertextRefusal": "加密状态被拒绝", + "logs.detail.cause.payloadTooLarge": "负载过大", + "logs.detail.cause.payloadRejected": "负载被拒绝", + "logs.detail.cause.upstreamFault": "上游故障", + "logs.detail.cause.emptyOutput": "没有可用输出", + "logs.detail.cause.clientCancelled": "客户端已取消", + "logs.detail.cause.localRefusal": "被此代理拒绝", + "logs.detail.stage.preHeader": "无响应头", + "logs.detail.stage.headersOnly": "仅响应头", + "logs.detail.stage.protocolPrelude": "协议前导", + "logs.detail.stage.semanticOutput": "已交付输出", + "logs.detail.stage.sideEffect": "已产生副作用", + "logs.detail.stage.terminal": "已交付答复", + "logs.detail.resend.permitted": "可以重发", + "logs.detail.resend.permittedAfterRepair": "修复后可以重发", + "logs.detail.resend.refusedAmbiguous": "未重发:上游状态未知", + "logs.detail.resend.refusedCommitted": "未重发:调用方已收到输出", + "logs.detail.resend.refusedFutile": "未重发:相同请求仍会失败", "logs.detail.reason.usage_missing": "未上报 usage。", "logs.detail.reason.usage_unsupported": "该提供方不支持上报 usage。", "logs.detail.reason.output_missing": "未上报正数输出 token。", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 529e62cef74..9f55f37e45d 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -28,7 +28,13 @@ import { validCachedRouteDecision, } from "./log-route-decision"; import { mergeLogDelta, parseLogPollResponse } from "./log-poll"; -import type { AttemptRecoveryKind, RequestSpendTotals } from "../../../src/usage/telemetry-contract"; +import type { + AttemptRecoveryKind, + RequestFailureCause, + RequestFailureStage, + RequestSpendTotals, + ResendPermission, +} from "../../../src/usage/telemetry-contract"; import { classifyRequestOutcome, requestPhysicalSends, @@ -108,7 +114,21 @@ interface LogDisplayMetrics { cost: CostResult; } -interface LogAttempt { +/** + * The durable attribution and the verdict the API derives from it. + * + * `resendPermission` arrives computed rather than stored: the tables that decide it live in the + * proxy and a row must not be able to assert a permission the current tables would refuse. The + * page renders the answer and derives nothing of its own, which is the same rule that keeps the + * outcome class agreeing with the exporter. + */ +interface LogFailureAttribution { + failureStage?: RequestFailureStage; + failureCause?: RequestFailureCause; + resendPermission?: ResendPermission; +} + +interface LogAttempt extends LogFailureAttribution { ordinal: number; provider: string; model: string; @@ -130,7 +150,7 @@ interface LogAttempt { displayMetrics?: LogDisplayMetrics; } -export interface LogEntry { +export interface LogEntry extends LogFailureAttribution { requestId?: string; timestamp: number; model: string; @@ -353,6 +373,51 @@ const OUTCOME_KEYS = { aborted: "logs.detail.outcome.aborted", } as const satisfies Record; +/** + * i18n key for each shared failure cause, total by construction. + * + * The `satisfies` clause is the point. The recovery-kind catalog on this page drifted to nine of + * the durable thirteen and four real causes reached the operator as "Unknown recovery reason" -- + * an absence of a label rendered as an absence of a cause. A missing member here is a typecheck + * failure instead. + */ +const FAILURE_CAUSE_KEYS = { + "transport-unsent": "logs.detail.cause.transportUnsent", + "transport-ambiguous": "logs.detail.cause.transportAmbiguous", + "upstream-declined": "logs.detail.cause.upstreamDeclined", + "rate-limit": "logs.detail.cause.rateLimit", + "quota-exhausted": "logs.detail.cause.quotaExhausted", + "credential-rejected": "logs.detail.cause.credentialRejected", + "policy-refusal": "logs.detail.cause.policyRefusal", + "parameter-rejected": "logs.detail.cause.parameterRejected", + "ciphertext-refusal": "logs.detail.cause.ciphertextRefusal", + "payload-too-large": "logs.detail.cause.payloadTooLarge", + "payload-rejected": "logs.detail.cause.payloadRejected", + "upstream-fault": "logs.detail.cause.upstreamFault", + "empty-output": "logs.detail.cause.emptyOutput", + "client-cancelled": "logs.detail.cause.clientCancelled", + "local-refusal": "logs.detail.cause.localRefusal", +} as const satisfies Record; + +/** i18n key for each stage the caller's view of the exchange reached. */ +const FAILURE_STAGE_KEYS = { + "pre-header": "logs.detail.stage.preHeader", + "headers-only": "logs.detail.stage.headersOnly", + "protocol-prelude": "logs.detail.stage.protocolPrelude", + "semantic-output": "logs.detail.stage.semanticOutput", + "side-effect": "logs.detail.stage.sideEffect", + "terminal": "logs.detail.stage.terminal", +} as const satisfies Record; + +/** i18n key for each resend verdict; every refusal names which refusal it is. */ +const RESEND_PERMISSION_KEYS = { + "permitted": "logs.detail.resend.permitted", + "permitted-after-repair": "logs.detail.resend.permittedAfterRepair", + "refused-ambiguous": "logs.detail.resend.refusedAmbiguous", + "refused-committed": "logs.detail.resend.refusedCommitted", + "refused-futile": "logs.detail.resend.refusedFutile", +} as const satisfies Record; + /** * How this request ended, using the same classifier the Prometheus exporter uses. * @@ -364,6 +429,27 @@ function outcomeKey(entry: Pick= 200 && status < 300) return "var(--green)"; if (status >= 400) return "var(--red)"; @@ -967,6 +1053,7 @@ function LogDetailDialog({ const tokenSplit = cacheSplit(detail); const cost = detail.displayMetrics?.cost; const reasoningWire = reasoningWireLabel(detail); + const detailFailure = failureAttributionLabels(detail, t); const copyRequestId = async () => { if (!detail.requestId) return; @@ -1003,6 +1090,21 @@ function LogDetailDialog({ {t("logs.col.time")}{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)} {t("logs.detail.outcome.label")} {t(outcomeKey(detail))} + {detailFailure.cause && ( + <> + {t("logs.detail.cause.label")} + + {detailFailure.cause} + {detailFailure.stage && ` (${t("logs.detail.stage.label")}: ${detailFailure.stage})`} + + + )} + {detailFailure.resend && ( + <> + {t("logs.detail.resend.label")} + {detailFailure.resend} + + )} {detail.spend && ( <> {t("logs.detail.sends.label")} @@ -1163,7 +1265,13 @@ function LogDetailDialog({ const attemptCost = attempt.displayMetrics?.cost; const attemptReasoningWire = reasoningWireLabel(attempt); const matched = attemptCost?.kind === "value" ? attemptCost.estimate.price : undefined; - const reason = attempt.errorCode + const attemptFailure = failureAttributionLabels(attempt, t); + // The derived cause leads, because it is the one value in this row that says + // WHY in a vocabulary an operator can act on. `errorCode` stays behind it + // rather than being dropped: it is the exact wire code, which is what a bug + // report needs. + const reason = attemptFailure.cause + ?? attempt.errorCode ?? (attempt.recoveryKinds.length ? attempt.recoveryKinds.map(kind => t(recoveryKindKey(kind))).join(", ") : undefined) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 83656758dba..1e5d3154b1c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1194,6 +1194,7 @@ "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", "failure-stage-model.test.ts": "lib", + "failure-attribution.test.ts": "lib", "relay-eager.test.ts": "server", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", @@ -1232,6 +1233,10 @@ "request-log-estimate-cap.test.ts": "usage", "request-log.test.ts": "usage", "request-outcome-agreement.test.ts": "usage", + "usage-failure-fingerprint.test.ts": "usage", + "usage-failure-projection.test.ts": "usage", + "usage-attempt-delivery.test.ts": "usage", + "usage-ledger-retention.test.ts": "usage", "request-pacing.test.ts": "usage", "reserve-auth-context.test.ts": "codex-integration", "reserve-availability.test.ts": "codex-integration", diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index cdc9cd1a02e..ccb03b62087 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -7,6 +7,7 @@ import type { OcxUsage, } from "../types"; import { coerceIntegerToolArguments } from "../lib/tool-argument-integers"; +import { attemptDeliveryRecorder } from "../usage/attempt-delivery"; import { adapterFailureFromMessage, classifyError, @@ -51,7 +52,14 @@ export function buildResponseJSON( ): Record { // Default-budget safety net: a caller that omits the budget gets a bounded // default (disposed with the call), never the unbounded append path. - if (options?.translatorBudget) return buildResponseJSONWithBudget(events, modelId, options); + if (options?.translatorBudget) { + const body = buildResponseJSONWithBudget(events, modelId, options); + // A buffered turn delivers its whole answer as one body, so nothing calls the per-frame + // recorder on the SSE bridge. Without this the attempt would persist adapter events with + // zero relayed ones, which is the loss signal -- raised on every non-streaming request. + attemptDeliveryRecorder(options.translatorBudget)?.noteBufferedDelivery(body); + return body; + } const budget = createTranslatorBudget(); try { return buildResponseJSONWithBudget(events, modelId, { ...options, translatorBudget: budget }); diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 0466963eb57..a1f5a0fb8a4 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -16,6 +16,7 @@ import { type OcxErrorPayload, } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; +import { attemptDeliveryRecorder, classifyRelayedResponseEvent } from "../usage/attempt-delivery"; import { mayBecomePatchEnvelope, repairFreeformToolInput, @@ -162,6 +163,10 @@ export function bridgeToResponsesSSE( // at terminal/cancel below. const ownsBudget = !options?.translatorBudget; const budget = options?.translatorBudget ?? createTranslatorBudget(); + // Resolved from the CALLER's budget only. A bridge that owns its budget is not serving a + // logged request -- there is no attempt to count against, and a locally created scope would + // never have had a recorder bound to it. + const delivery = attemptDeliveryRecorder(options?.translatorBudget); // Idempotent: safe to call at every stream-death path; disposal must come // AFTER the final charges (emitDone), never inside reportTerminal. const disposeOwnedBudget = () => { if (ownsBudget) budget.dispose(); }; @@ -278,6 +283,11 @@ export function bridgeToResponsesSSE( controller.enqueue(frame); budget?.releaseRetained(frameBytes, { kind: "live_transient" }); emittedFrames++; + // After a SUCCESSFUL enqueue, never before it. A frame that threw on the way to the + // transport did not reach the caller, and counting it here would make the relayed + // total equal the adapter total by construction -- erasing the one discrepancy these + // counters exist to expose (#3983). + delivery?.noteRelayedEvent(classifyRelayedResponseEvent(name, data)); } catch (error) { if (isTranslatorBudgetExceededError(error)) { terminateForTranslatorOverflow?.(error); diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 27f4471d402..2bdb9508e2c 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -2,6 +2,7 @@ import { chmodSync, closeSync, fchmodSync, + fsyncSync, fstatSync, lstatSync, openSync, @@ -162,6 +163,27 @@ function carryHardenAcrossContentWrite(path: string): void { reattributeHardenedSecretPath(path); } +/** + * Commit the directory entry a rename just wrote. + * + * Best effort by platform, not by importance: Windows has no directory descriptor to sync and + * some filesystems refuse the open, and failing a replacement that already happened would be + * worse than reporting it. The throw that matters is the temp's own `fsync`, which runs before + * the rename and stops it. + */ +function syncParentDirectory(target: string): void { + if (process.platform === "win32") return; + let descriptor: number | undefined; + try { + descriptor = openSync(dirname(target), "r"); + fsyncSync(descriptor); + } catch { + /* the rename already landed; a directory that cannot be synced is not a reason to undo it */ + } finally { + if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* already closed */ } } + } +} + function writePrivateTempFile( path: string, content: string, @@ -191,6 +213,40 @@ function writePrivateTempFile( carryHardenAcrossContentWrite(path); } +/** + * The same private temp, filled by a writer that streams into the descriptor. + * + * For content that must not be held in memory as one string. The identity assertions, the + * ownership handshake and the hardening are the same; the difference is that the bytes arrive in + * bounded chunks and the descriptor is flushed before it closes. + * + * The `fsync` is not optional here and its failure is not swallowed. A replacement whose + * REPLACEMENT is not on disk can lose the rows it was supposed to retain, so the throw is what + * stops the rename from happening at all. + */ +function writePrivateTempFileWith( + path: string, + write: (descriptor: number) => void, + timeoutMemoKey: string, + onCreated: () => void, +): void { + const descriptor = openSync(path, "wx", 0o600); + onCreated(); + try { + if (windowsHardeningApplies()) { + hardenSecretPath(path, { required: true, timeoutMemoKey }); + } + if (process.platform !== "win32") fchmodSync(descriptor, 0o600); + assertPrivateTempDescriptor(path, descriptor); + write(descriptor); + assertPrivateTempDescriptor(path, descriptor); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + carryHardenAcrossContentWrite(path); +} + async function writePrivateTempFileAsync( path: string, content: string, @@ -217,7 +273,7 @@ async function writePrivateTempFileAsync( function atomicWriteFileToTarget( path: string, - content: string, + content: string | ((descriptor: number) => void), target: string, io?: AtomicWriteIO, hooks: AtomicWriteHooks = {}, @@ -246,13 +302,26 @@ function atomicWriteFileToTarget( }; try { if (io) ownsTemp = true; - effective.write(tmp, content); + // A streaming writer bypasses the string form of `write` and nothing else. Every later + // step -- harden, the pre-rename hooks, the rename and the whole residual-cleanup path, + // which still scrubs through `effective.write(tmp, "")` -- is shared with the string form. + if (typeof content === "function") writePrivateTempFileWith(tmp, content, path, () => { ownsTemp = true; }); + else effective.write(tmp, content); hooks.afterTempWrite?.(tmp, target); effective.harden(tmp); hardened = true; hooks.beforeRename?.(tmp, target); hooks.validateBeforeRename?.(target); effective.rename(tmp, target); + // The rename is only as durable as the directory entry recording it. Fsyncing the temp's + // CONTENT and then losing the entry in a power cut leaves the old file in place, or the + // directory in an indeterminate state, while the caller was told the replacement landed. + // + // Only the streaming form does this. It is the one that makes a durability claim -- a + // replacement is not an append, and losing it can lose the rows it was meant to keep -- and + // adding a directory sync to the string form would charge every config write for a promise + // its callers have never been given. + if (typeof content === "function") syncParentDirectory(target); forgetEphemeralSecretPath(tmp); } catch (cause) { if (!ownsTemp) throw cause; @@ -296,6 +365,23 @@ export function atomicWriteFile( atomicWriteFileToTarget(path, content, resolveWriteTarget(path), io, hooks); } +/** + * Atomically replace a file with bytes produced straight into the temporary descriptor. + * + * Same publication contract as {@link atomicWriteFile}: an exclusively created private temp, the + * identity assertions around the write, `hooks.validateBeforeRename` immediately before the + * rename, the platform-aware replace, and the residual cleanup on any failure. A custom + * {@link AtomicWriteIO} is not accepted, because the point of this form is that the default + * writer owns the descriptor. + */ +export function atomicWriteFileStreamed( + path: string, + write: (descriptor: number) => void, + hooks: AtomicWriteHooks = {}, +): void { + atomicWriteFileToTarget(path, write, resolveWriteTarget(path), undefined, hooks); +} + /** * Atomically replace the named directory entry without resolving a symlink at * that entry. This is for files in directories writable by another process: diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 1711b8b314a..6d1e936670c 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -46,6 +46,7 @@ import { MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, } from "../../codex/account-namespace-match"; import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "../../codex/upstream-host-health"; +import { MIN_USAGE_LEDGER_MAX_BYTES } from "../../usage/retention-contract"; import { COMBO_NAMESPACE, comboConfigIssues } from "../../combos/types"; import { routingProfileIssues } from "../../routing/profile"; import { POLICY_NAMESPACE } from "../../routing/profile-namespace"; @@ -82,6 +83,14 @@ export const configSchema = z.object({ managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024).describe( "Deprecated compatibility limit for bounded legacy usage readers; GET /api/usage always aggregates the complete ledger", ), + // Opt-in ledger ceiling. A hand edit below the floor, or a non-safe integer, disables only + // this limit rather than failing the config: refusing to start because history retention was + // mistyped would be a worse outcome than not trimming history. + usageLedgerMaxBytes: z.number().int() + .min(MIN_USAGE_LEDGER_MAX_BYTES) + .max(Number.MAX_SAFE_INTEGER) + .optional() + .catch(undefined), // Invalid hand edits disable only this opt-in circuit. Live writes remain strict. upstreamHostCircuitThreshold: z.number().int() .min(0) diff --git a/src/lib/debug.ts b/src/lib/debug.ts index 938e5737ea7..9167b6ccbd0 100644 --- a/src/lib/debug.ts +++ b/src/lib/debug.ts @@ -52,3 +52,43 @@ export function debugProviderDiagnosticLazy( /* diagnostics must never affect request handling */ } } + +/** + * One line per finalized attempt, formatted from what the recorder already counted. + * + * #3983 wanted this visibility and emitted a line per stream event to get it. Two things made + * that the wrong shape. It is a second record: `emitDebugLine` writes the ring AND stderr, and + * a service manager redirects stderr to a file, so an installed service accumulates a per-event + * history beside the ledger with its own retention and sequencing. And per-event lines needed a + * per-payload fingerprint to correlate, which under a process-global key makes every repeated + * prompt fragment and tool name correlatable for the life of the process. + * + * So this writes the ring ONLY -- `appendDebugLogLine` directly, never `emitDebugLine` -- and + * says nothing the ledger does not already hold. The ring becomes a live view of the durable + * record rather than a parallel source for it. + */ +export function debugAttemptDeliverySummary( + requestId: string, + attempt: { + ordinal: number; + adapter: string; + deliverySummary?: { + adapterEvents: number; + relayedEvents: number; + semanticBytes: number; + sideEffectEvents: number; + terminalEvents: number; + }; + }, +): void { + if (!isDebugEnabled() || !attempt.deliverySummary) return; + try { + appendDebugLogLine(`[ocx:${attempt.adapter}:delivery] ${JSON.stringify({ + requestId, + ordinal: attempt.ordinal, + ...attempt.deliverySummary, + })}`); + } catch { + /* diagnostics must never affect request handling */ + } +} diff --git a/src/lib/request-failure-attribution.ts b/src/lib/request-failure-attribution.ts new file mode 100644 index 00000000000..098af493003 --- /dev/null +++ b/src/lib/request-failure-attribution.ts @@ -0,0 +1,183 @@ +/** + * Derive how far a failed request got and why, from the closed facts the recorder already holds. + * + * #2366 asked for durable failure attribution and shipped its own `FailureSide` and seven-member + * `FailureStage` to carry it. Those are a second attribution vocabulary beside the one that + * landed in {@link ../lib/request-failure-model}, and two vocabularies for one question is the + * class of defect that blocked 2.60.0. This module is the same answer expressed in the landed + * vocabulary: no new stage names, no new cause names, and no new record store. + * + * Everything it reads is a CLOSED value the row already carries -- an HTTP status, a terminal + * status, a close reason, a transport phase, a recovery kind. It never reads `errorCode` or + * `upstreamError`, which are open strings assembled partly from upstream text: a classification + * keyed on those is a different answer per provider and per locale, and a grouping key built from + * them cannot promise it carries no content. + * + * MUST stay a leaf. Its only imports are types and the two tables it decides with, so nothing + * here can pull the usage or budget subsystems into a request path that lacked them. + */ +import type { AttemptRecoveryKind, RequestFailureCause, RequestFailureStage } from "../usage/telemetry-contract"; +import { causeForRecoveryKind } from "./request-failure-model"; +import { classifyRequestOutcome, type RequestOutcomeFacts } from "../usage/request-outcome"; + +/** + * What the recorder knows about one finished exchange at the single seam every request passes. + * + * Deliberately the same narrow set {@link RequestOutcomeFacts} reads, plus the four observation + * facts a stage needs. A field that could carry a provider name, a model, an account or upstream + * text is absent by construction rather than by review. + */ +export interface RequestFailureFacts extends RequestOutcomeFacts { + readonly transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse" | undefined; + /** Where the status and message came from: an origin response, or a tail this proxy wrote. */ + readonly terminalSource?: "upstream" | "synthetic" | undefined; + /** True when the upstream stream died after its head was committed. */ + readonly streamAborted?: boolean | undefined; + /** True once any output-bearing event reached the caller; `firstOutputMs` is the usual source. */ + readonly outputObserved?: boolean | undefined; + /** True once a tool call or other externally visible effect was relayed to the caller. */ + readonly sideEffectObserved?: boolean | undefined; + /** True when this proxy answered the turn itself and issued no upstream request. */ + readonly locallyAnswered?: boolean | undefined; + /** + * Recovery kinds recorded on the attempt that ended the request, in the order they happened. + * Only the LAST one is ever consulted, and only under the narrow rule below. + */ + readonly recoveryKinds?: readonly AttemptRecoveryKind[] | undefined; + /** + * A cause the CALLER proved, which the status alone cannot reconstruct. + * + * Set only by a finalizer that is sealing an attempt it knows the rejection for -- the + * key-account rotation seals the previous attempt because a named recovery rejected it, and + * that argument is direct evidence rather than an inference from history. It outranks the + * status table and is outranked by a client cancel, which is a fact about the caller and not + * about the origin. + */ + readonly causeHint?: RequestFailureCause | undefined; +} + +/** + * How far the caller's view of the exchange got. + * + * Total by construction and ordered downward from the most committed observation, so a fact that + * proves a later stage wins over one that only proves an earlier one. The boundary between + * `headers-only` and `protocol-prelude` is the one genuinely debatable step -- a non-streaming + * 4xx error body is a body, but not a protocol body event -- and it is safe to argue about + * because both stages carry the same `nothing-observed` commitment, so no resend decision turns + * on which side of it a row lands. + */ +export function deriveRequestFailureStage(facts: RequestFailureFacts): RequestFailureStage { + if (facts.terminalStatus === "completed" && facts.outputObserved === true) return "terminal"; + if (facts.sideEffectObserved === true) return "side-effect"; + if (facts.outputObserved === true) return "semantic-output"; + if (facts.terminalStatus !== undefined + || facts.closeReason === "terminal" + || facts.transportPhase === "mid_stream" + || facts.transportPhase === "terminal_sse") return "protocol-prelude"; + if (facts.status >= 100) return "headers-only"; + return "pre-header"; +} + +/** + * The two recovery kinds that name a 4xx the status alone cannot tell apart. + * + * `causeForRecoveryKind` answers why a recovery was ATTEMPTED, which is usually a different + * question from why the request finally failed -- one that recovered from a 401 and then died on + * a 500 failed for the 500. So the rule here is deliberately narrow on three axes at once: only + * these two kinds, only when they are the LAST recovery this attempt recorded, and only when the + * attempt then ended on the very status that recovery was made for. Everything else falls + * through to the status table. + * + * The residual: a ciphertext recovery that SUCCEEDED, followed by an unrelated 400 on the same + * attempt, still reads as `ciphertext-refusal`, because a successful recovery does not currently + * clear its own evidence. Closing that belongs in the recovery path rather than here -- it is + * recorded in this lane's devlog as the next step -- and the rule is kept meanwhile because + * without it a rejected ciphertext, a rejected reasoning parameter and a rejected payload are one + * undifferentiated answer, which is three different remedies collapsed into one. + */ +const STATUS_CONFIRMED_RECOVERY_KINDS: Readonly>> = Object.freeze({ + "opaque-blob-rejection": 400, + "reasoning-effort-downgrade": 400, +}); + +function refinedFourHundredCause( + facts: RequestFailureFacts, +): RequestFailureCause | undefined { + const last = facts.recoveryKinds?.at(-1); + if (last === undefined) return undefined; + const confirmedStatus = STATUS_CONFIRMED_RECOVERY_KINDS[last]; + return confirmedStatus === facts.status ? causeForRecoveryKind(last) : undefined; +} + +/** + * Why the request failed. + * + * Returns `undefined` for an outcome that is not a failure. An incomplete turn is a real + * shortfall and gets a stage, but this dictionary answers "why did it fail", and a turn cut short + * by `max_output_tokens` did not fail for any of these reasons; inventing one would put a + * fabricated cause into a metric label and a grouping key. + * + * The status is the primary evidence because it is the one fact every transport produces. Two + * refinements sit above it, both from closed values: a client cancel is known from the close + * reason before any status is consulted, and a 400 that a recovery kind identified as a rejected + * ciphertext or a rejected reasoning parameter is not the same answer as a rejected payload. + */ +export function deriveRequestFailureCause(facts: RequestFailureFacts): RequestFailureCause | undefined { + const outcome = classifyRequestOutcome(facts); + if (outcome === "completed" || outcome === "incomplete") return undefined; + if (outcome === "aborted") return "client-cancelled"; + if (facts.locallyAnswered === true) return "local-refusal"; + // A cause the finalizer proved outranks anything reconstructed from the status. + if (facts.causeHint !== undefined) return facts.causeHint; + + const status = facts.status; + // Transport evidence outranks the numeric status, because a stream that died mid-flight is + // reported as a SYNTHETIC 502 -- a tail this proxy wrote, not an answer the origin gave. Read + // in status order that 502 becomes `upstream-fault`, which claims the origin answered when it + // did not. Both causes refuse an automatic resend, so this is an accuracy fix rather than a + // safety one, but a label an operator cannot trust is a label they stop reading. + if (facts.streamAborted === true + || (facts.terminalSource === "synthetic" + && (facts.transportPhase === "mid_stream" || facts.transportPhase === "terminal_sse"))) { + return "transport-ambiguous"; + } + // A 2xx head that carried a failed terminal: the origin ran the turn and said it failed. With + // no output relayed the useful distinction is that nothing usable came back at all. + if (status >= 100 && status < 400) { + return facts.outputObserved === true ? "upstream-fault" : "empty-output"; + } + if (status === 401) return "credential-rejected"; + if (status === 403) return "credential-rejected"; + // Payment required. Waiting out a retry window does not help; the account has to change. + if (status === 402) return "quota-exhausted"; + if (status === 413) return "payload-too-large"; + if (status === 429) return "rate-limit"; + if (status === 451) return "policy-refusal"; + if (status === 503) return "upstream-declined"; + if (status >= 500) return "upstream-fault"; + if (status >= 400) return refinedFourHundredCause(facts) ?? "payload-rejected"; + // No response head at all, and nothing proved the bytes never left. `transport-ambiguous` is + // the honest answer for an unknown execution state, and it is the safe one: it refuses an + // automatic resend where `transport-unsent` would permit one. `transport-unsent` is reachable + // only through `causeHint`, from a site that classified a pre-connect failure and can prove it. + return "transport-ambiguous"; +} + +export interface RequestFailureAttribution { + stage: RequestFailureStage; + cause?: RequestFailureCause; +} + +/** + * The attribution to persist, or `undefined` when the request completed. + * + * A completed request has no failure to attribute, and recording a stage for one would put a + * `terminal` row into every grouping that exists to find failures. + */ +export function deriveRequestFailureAttribution( + facts: RequestFailureFacts, +): RequestFailureAttribution | undefined { + if (classifyRequestOutcome(facts) === "completed") return undefined; + const cause = deriveRequestFailureCause(facts); + return { stage: deriveRequestFailureStage(facts), ...(cause ? { cause } : {}) }; +} diff --git a/src/lib/request-failure-model.ts b/src/lib/request-failure-model.ts index f12517f2a45..1fc3211a66b 100644 --- a/src/lib/request-failure-model.ts +++ b/src/lib/request-failure-model.ts @@ -12,41 +12,42 @@ * `PersistedUsageEntry` in src/usage/log.ts, and every projection below reads those structurally * rather than growing a parallel history. * - * MUST stay a leaf. Its only imports are types, erased at runtime, so nothing here can pull the - * usage or budget subsystems into a request path that did not already have them. + * MUST stay a leaf. Its one runtime import is `src/usage/telemetry-contract.ts`, which has no + * imports at all; everything else it names is a type, erased at runtime. So nothing here can + * pull the usage or budget subsystems into a request path that did not already have them. */ import type { SendClass } from "./request-execution-budget"; -import type { AttemptRecoveryKind } from "../usage/telemetry-contract"; +import { + REQUEST_FAILURE_STAGES, + type AttemptRecoveryKind, + type RequestFailureCause, + type RequestFailureStage, + type ResendPermission, +} from "../usage/telemetry-contract"; /** - * How far the exchange got, ordered by how much the DOWNSTREAM CLIENT observed. + * The vocabulary this module decides over is DECLARED in `src/usage/telemetry-contract.ts` and + * re-exported here, so every importer of this module keeps its path while the dashboard can + * reach the same rosters without pulling this file's import graph into the browser project. * - * The order is by client observation rather than by upstream progress, because the question the - * table answers is whether resending can duplicate something the caller already saw. An upstream - * that completed a turn we never relayed has committed nothing downstream; an upstream that - * emitted one token has. + * What stays here is the decision: the per-stage commitment, the per-cause evidence and + * disposition, and the resend permission derived from them. * * A stage is how far the OBSERVABLE progression got, not which events happened to arrive. A turn * that settled carrying no output -- an empty completion, a 4xx error body -- did not reach - * `terminal`; it stalled at `protocol-prelude`, because the caller saw no answer. `terminal` + * `terminal`; it stalled below `semantic-output`, because the caller saw no answer. `terminal` * means the answer was delivered, which is why it is both last and refused. */ -export const REQUEST_FAILURE_STAGES = Object.freeze([ - /** No response head exists. Whether the origin began the turn is not known from the stage alone. */ - "pre-header", - /** A status line and headers exist, and no protocol body event has been parsed yet. */ - "headers-only", - /** The protocol body began with control events only -- `response.created`, quota frames. */ - "protocol-prelude", - /** At least one output-bearing event reached the caller. */ - "semantic-output", - /** A tool call or other externally visible effect was emitted. */ - "side-effect", - /** A terminal event settled the turn after its answer reached the caller. */ - "terminal", -] as const); - -export type RequestFailureStage = typeof REQUEST_FAILURE_STAGES[number]; +export { + REQUEST_FAILURE_CAUSES, + REQUEST_FAILURE_STAGES, + RESEND_PERMISSIONS, +} from "../usage/telemetry-contract"; +export type { + RequestFailureCause, + RequestFailureStage, + ResendPermission, +} from "../usage/telemetry-contract"; /** Position in {@link REQUEST_FAILURE_STAGES}. Derived, so the order is stated exactly once. */ export function stageRank(stage: RequestFailureStage): number { @@ -74,61 +75,6 @@ export function stageCommitment(stage: RequestFailureStage): StageCommitment { return STAGE_COMMITMENT[stage]; } -/** - * Why the request failed, as one closed dictionary for every layer. - * - * Bounded on purpose: these are wire values a maintainer reads and a metric labels by, never a - * credential, an account identifier, an upstream body or prompt content. The four that #5180 and - * the ciphertext path insist on -- `rate-limit`, `quota-exhausted`, `policy-refusal` and - * `ciphertext-refusal` -- are separate members because they need opposite follow-ups: wait, - * change account, change the prompt, strip the ciphertext. - */ -export const REQUEST_FAILURE_CAUSES = Object.freeze([ - /** The bytes provably never reached the origin: connect refused, DNS failure, TLS handshake. */ - "transport-unsent", - /** The bytes left and the connection died before a head. The origin may be running the turn. */ - "transport-ambiguous", - /** The origin answered that it would not start the turn now: 503, overloaded, backpressure. */ - "upstream-declined", - /** A 429 rate limit. Capacity is momentarily gone; waiting is the remedy. */ - "rate-limit", - /** Plan or credit quota is gone. Waiting out a retry window does not help; the account must change. */ - "quota-exhausted", - /** Credentials were rejected: 401, 403 on identity. */ - "credential-rejected", - /** The origin evaluated the content and refused it. Identical bytes get the identical refusal. */ - "policy-refusal", - /** - * The origin rejected a request PARAMETER rather than the content: an unsupported reasoning - * effort, an unknown field. Distinct from `policy-refusal` because the remedy is opposite -- - * the same content succeeds once the parameter is adjusted. - */ - "parameter-rejected", - /** Opaque replay state was rejected as unverifiable. Only a request without it can succeed. */ - "ciphertext-refusal", - /** - * The payload exceeded a size the origin accepts. A smaller rebuild of the same turn can - * succeed, which is why this is not the same answer as `payload-rejected`. - */ - "payload-too-large", - /** The payload was rejected on its merits: unsupported media, malformed part. No repair helps. */ - "payload-rejected", - /** - * The origin returned a server-side fault. Whether it had already begun the turn is not - * knowable from the status, so this is the honest classification for the mixed 5xx set the - * transient layer retries: 503 really did decline, 500 may not have. - */ - "upstream-fault", - /** The turn settled carrying no usable output. */ - "empty-output", - /** The caller went away. */ - "client-cancelled", - /** This proxy refused before dispatch: send budget, route policy, replay refusal. */ - "local-refusal", -] as const); - -export type RequestFailureCause = typeof REQUEST_FAILURE_CAUSES[number]; - /** * What the cause proves about whether the origin ran the turn. * @@ -190,24 +136,6 @@ export function causeDisposition(cause: RequestFailureCause): ResendDisposition return CAUSE_DISPOSITION[cause]; } -/** - * The answer this table exists to give. - * - * Every refusal names WHY it refused, because the three reasons need different operator - * responses and used to arrive as one undifferentiated "no retry". - */ -export type ResendPermission = - /** The same request may be sent again. */ - | "permitted" - /** Only a modified request may be sent: rotated credential, stripped ciphertext. */ - | "permitted-after-repair" - /** Upstream execution state is unknown. No AUTOMATIC resend; see the note below. */ - | "refused-ambiguous" - /** The caller already observed output or an externally visible effect. */ - | "refused-committed" - /** Identical bytes would get the identical answer. */ - | "refused-futile"; - /** * Whether this proxy may send the request again, from the stage it failed at and the cause. * diff --git a/src/server/index.ts b/src/server/index.ts index dc3462d638a..5ab9dccb34f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -111,6 +111,7 @@ import { type RequestLogEntry, } from "./request-log"; import { sessionLaneIdFromRequest } from "./request-log-conversation"; +import { setUsageLedgerRetention } from "./usage-ledger-retention"; import { admitHttpWorkflowTurn, workflowDecisionRefusalResponse, type WorkflowRefusalLog } from "./workflow-refusal"; export { addFinalRequestLog, @@ -303,6 +304,9 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe enforceAppOwnedMemoryBudget(); // Observe-only mode still journals physical sends, so every server owns before configuring. spendLedgerLifecycle.configure(config.spend); + // After ownership: a second server on the same home is refused above, so the process running + // this line is the only one appending to usage.jsonl and the only one that may compact it. + setUsageLedgerRetention(config.usageLedgerMaxBytes); registerCodexCooldownRecoveryProbeWorker(config); // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly // tick for daily/weekly; startup evaluation is fire-and-forget after listen. diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index cd1e3ed3b0f..51bf12725ea 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -22,6 +22,7 @@ import { upsertOAuthProvider, } from "../../oauth"; import { removeCredential } from "../../oauth/store"; +import { getFailureProjection } from "../../usage/failure-projection-cache"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; @@ -179,6 +180,24 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { return { ...entry, + ...resendVerdict(entry), displayMetrics: { tokPerSecond: tokPerSecondResult(entry), // The parent uses the REQUEST's own TTFT. A combo parent must not borrow an attempt's, @@ -227,6 +243,7 @@ export function requestLogDto( ? { attempts: entry.attempts.map(attempt => ({ ...attempt, + ...resendVerdict(attempt), displayMetrics: { tokPerSecond: tokPerSecondResult(attempt), // Each attempt measures its own attempt-relative TTFT. diff --git a/src/server/request-log-failure-attribution.ts b/src/server/request-log-failure-attribution.ts new file mode 100644 index 00000000000..9a22e0ba8bb --- /dev/null +++ b/src/server/request-log-failure-attribution.ts @@ -0,0 +1,99 @@ +/** + * Where a request's failure attribution is derived and stamped. + * + * A sibling of `request-log.ts` rather than a section inside it. That file carries the whole + * request-logging surface and sits against the 2,000-line repository ceiling, which only ever + * moves down; this is the extraction that rule asks for, not a cap that was negotiated. + * + * It holds the two places a stage and cause are decided and written. Both read the recorder's + * own closed facts and neither reads `errorCode` or `upstreamError`, which are assembled partly + * from upstream text. + */ +import { deriveRequestFailureAttribution } from "../lib/request-failure-attribution"; +import { causeForRecoveryKind } from "../lib/request-failure-model"; +import type { AttemptRecoveryKind, PersistedUsageAttempt, RequestFailureCause, RequestFailureStage } from "../usage/log"; +import type { ResponsesTerminalStatus } from "../bridge"; + +/** The subset of the log context this derivation may see. Narrow on purpose. */ +export interface FinalRequestAttributionFacts { + readonly status: number; + readonly terminalStatus?: ResponsesTerminalStatus | undefined; + readonly closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow" | undefined; + readonly transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse" | undefined; + readonly terminalSource?: "upstream" | "synthetic" | undefined; + /** The REQUEST's first-output observation, not the final attempt's. See below. */ + readonly outputObserved: boolean; + readonly locallyAnswered: boolean; + readonly attempt?: PersistedUsageAttempt | undefined; +} + +export interface StampedAttribution { + failureStage?: RequestFailureStage; + failureCause?: RequestFailureCause; +} + +/** + * Derive the attribution for a finished logical request and stamp the attempt that ended it. + * + * Called from the one seam every request passes exactly once, and BEFORE the attempt snapshot, + * so the row that reaches disk and the live attempt object carry the same pair. Deriving it at + * each transport's own exit would give the same request a different attribution per transport, + * which is the disagreement the shared terminal classifier already removed once. + * + * `outputObserved` is the request's observation rather than the final attempt's. A request that + * relayed output on its first attempt and then failed over has committed that output to the + * caller whatever the last attempt saw, so the logical row and the attempt that ended it carry + * the same answer. Reading the attempt-local value would produce a MORE permissive resend verdict + * for exactly that case, and a permission decision has to fail in the safe direction. + */ +export function attributeFinalRequest(facts: FinalRequestAttributionFacts): StampedAttribution { + const attribution = deriveRequestFailureAttribution({ + status: facts.status, + ...(facts.terminalStatus ? { terminalStatus: facts.terminalStatus } : {}), + ...(facts.closeReason ? { closeReason: facts.closeReason } : {}), + ...(facts.transportPhase ? { transportPhase: facts.transportPhase } : {}), + ...(facts.terminalSource ? { terminalSource: facts.terminalSource } : {}), + ...(facts.attempt?.streamAborted === true ? { streamAborted: true } : {}), + outputObserved: facts.outputObserved, + // The one fact that can raise a stage above `semantic-output`, and the reason it is counted + // at the transport rather than at the adapter: an emitted tool call the client never + // received has committed nothing, and a resend for it is still safe (#3983). + sideEffectObserved: (facts.attempt?.deliverySummary?.sideEffectEvents ?? 0) > 0, + locallyAnswered: facts.locallyAnswered, + recoveryKinds: facts.attempt?.recoveryKinds ?? [], + }); + // The final row and the attempt that ended it describe the same exchange, so they carry the + // same pair rather than each deriving one from a different slice of the facts. + if (facts.attempt) { + if (attribution?.stage) facts.attempt.failureStage = attribution.stage; + else delete facts.attempt.failureStage; + if (attribution?.cause) facts.attempt.failureCause = attribution.cause; + else delete facts.attempt.failureCause; + } + return { + ...(attribution?.stage ? { failureStage: attribution.stage } : {}), + ...(attribution?.cause ? { failureCause: attribution.cause } : {}), + }; +} + +/** + * Attribute an attempt being sealed because a named recovery rejected it. + * + * The recovery kind is direct evidence here rather than an inference from history: this attempt + * is ending precisely because that recovery was needed. Without it the sealed attempt would reach + * the ledger with no attribution at all, because the finalization seam only ever sees the last + * attempt of a request. + */ +export function attributeSealedAttempt( + attempt: PersistedUsageAttempt, + recovery: AttemptRecoveryKind | undefined, +): void { + const attribution = deriveRequestFailureAttribution({ + status: attempt.status, + outputObserved: attempt.firstOutputMs !== undefined, + sideEffectObserved: (attempt.deliverySummary?.sideEffectEvents ?? 0) > 0, + ...(recovery ? { causeHint: causeForRecoveryKind(recovery) } : {}), + }); + if (attribution?.stage) attempt.failureStage = attribution.stage; + if (attribution?.cause) attempt.failureCause = attribution.cause; +} diff --git a/src/server/request-log.ts b/src/server/request-log.ts index d7b4888ee58..ac1ad98270d 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -40,6 +40,7 @@ import { isLogicalRequestId, isValidReasoningWireValue, normalizeClaudeCompatibilityUsageLog, + normalizeRequestFailureAttribution, normalizeRequestSpend, readRecentUsageEntries, usageForFinalLog, @@ -52,9 +53,13 @@ import { type PersistedUsageAttempt, type PersistedUsageEntry, type PersistedClaudeCompatibilityLog, + type RequestFailureCause, + type RequestFailureStage, type UsageStatus, } from "../usage/log"; import type { RequestExecutionBudget } from "../lib/request-execution-budget"; +import { attributeFinalRequest, attributeSealedAttempt } from "./request-log-failure-attribution"; +import { debugAttemptDeliverySummary } from "../lib/debug"; import { appendUsageDebug, isUsageDebugEnabled, @@ -330,6 +335,13 @@ export interface RequestLogEntry { routeDecision?: RouteDecisionTraceV1; /** Closed Claude protocol codes; no request or header values. */ claudeCompatibility?: PersistedClaudeCompatibilityLog; + /** + * How far this request got and why it failed, in the shared stage and cause vocabulary + * (#2366). Derived once at the single finalization seam and carried on the row so the + * dashboard, the durable ledger and the exporter read one answer instead of three. + */ + failureStage?: RequestFailureStage; + failureCause?: RequestFailureCause; } const requestLog: RequestLogEntry[] = []; @@ -455,6 +467,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(entry.conversationStateScrub === "account-change" ? { conversationStateScrub: "account-change" } : {}), + ...normalizeRequestFailureAttribution(entry), }; } @@ -515,6 +528,24 @@ export function hydrateRequestLogsFromDisk( } } +/** + * Rebuild the Logs ring after retention deleted rows from the ledger. + * + * Without this a compaction is invisible where an operator actually looks: the ring holds up to + * 2,000 entries independently of the file, so rows deleted from disk keep serving through + * /api/logs until eviction or a restart -- the dashboard showing history the ledger no longer + * has. Observers are deliberately not replayed; they exist to watch NEW rows arrive, and + * replaying a rehydration through them would announce two thousand arrivals that did not happen. + */ +export function rehydrateRequestLogsAfterLedgerReplacement( + reader: () => PersistedUsageEntry[] = () => readRecentUsageEntries(MAX_LOG_SIZE), +): number { + requestLog.length = 0; + requestLogBytes = 0; + requestLogsHydratedFromDisk = false; + return hydrateRequestLogsFromDisk(reader); +} + export function addRequestLog(entry: RequestLogEntry) { // Sanitize ONCE, at the ingress, and use that one value for both destinations. // @@ -601,6 +632,10 @@ export function addRequestLog(entry: RequestLogEntry) { ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}), ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}), ...failureDiagnostics, + // Rebuilt explicitly, like every other field here: this function does not spread the + // entry, so a pair omitted at this line would reach /api/logs and never reach + // usage.jsonl, which is the surface the derived failure projection reads. + ...normalizeRequestFailureAttribution(entry), ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}), ...(entry.conversationStateScrub === "account-change" @@ -1369,6 +1404,18 @@ export function addFinalRequestLog( if (errorCode) logCtx.activeAttempt.errorCode = errorCode; else delete logCtx.activeAttempt.errorCode; } + // Derived and stamped in a sibling module, before the attempt snapshot below. Every input is + // a closed value; the open error strings are deliberately not among them. + const attribution = attributeFinalRequest({ + status: effectiveStatus, + ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}), + ...(closeReason ? { closeReason } : {}), + ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), + ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), + outputObserved: logCtx.firstOutputMs !== undefined, + locallyAnswered: logCtx.localTerminalReason !== undefined, + ...(logCtx.activeAttempt ? { attempt: logCtx.activeAttempt } : {}), + }); // The one seam every request passes exactly once, whatever transport served it and however // it ended. The terminal usage belongs to the last send that left; the ledger resolves every // earlier send of this request as unresolved spend rather than handing its tokens back. @@ -1386,6 +1433,10 @@ export function addFinalRequestLog( ...(attempt.recoveryWithheld?.length ? { recoveryWithheld: [...attempt.recoveryWithheld] } : {}), ...(attempt.usage ? { usage: { ...attempt.usage } } : {}), ...(attempt.tierOutcome ? { tierOutcome: { ...attempt.tierOutcome } } : {}), + // Detached, like every mutable field beside it: the live summary keeps counting if the + // stream is still draining, and a shared reference would let a finalized row change after + // it was written. + ...(attempt.deliverySummary ? { deliverySummary: { ...attempt.deliverySummary } } : {}), })); const isCombo = logCtx.comboId !== undefined && (attempts?.length ?? 0) > 0; const aggregate = isCombo ? aggregateAttemptUsage(attempts ?? []) : null; @@ -1403,6 +1454,7 @@ export function addFinalRequestLog( ...(closeReason ? { closeReason } : {}), ...(attempts !== undefined ? { attempts } : {}), ...(spend ? { spendSends: spend.sends } : {}), + ...(attribution.failureCause ? { failureCause: attribution.failureCause } : {}), }); const cacheProvenance = classifyCacheTelemetryProvenance(loggedUsage, { wireParsed: logCtx.usageWireParsed === true, @@ -1493,7 +1545,10 @@ export function addFinalRequestLog( ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), + ...attribution, }); + // Formatted from the finalized snapshot, so the ring shows exactly what the ledger holds. + for (const attempt of attempts ?? []) debugAttemptDeliverySummary(requestId, attempt); if (isUsageDebugEnabled()) { appendUsageDebug({ ts: Date.now(), @@ -1740,8 +1795,14 @@ export function noteProviderAttemptSend( finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502, Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage); + // This attempt is being sealed because a NAMED recovery rejected it, so the recovery kind + // is direct evidence here rather than an inference from history. Without this the sealed + // attempt would reach the ledger with no attribution at all: the finalization seam below + // only ever sees the last attempt of the request. + attributeSealedAttempt(attempt, recovery); const completed = { ...attempt, recoveryKinds: [...attempt.recoveryKinds], ...(attempt.usage ? { usage: { ...attempt.usage } } : {}), + ...(attempt.deliverySummary ? { deliverySummary: { ...attempt.deliverySummary } } : {}), ...(attempt.tierOutcome ? { tierOutcome: { ...attempt.tierOutcome } } : {}) }; const attempts = logCtx.attempts ??= [attempt]; const index = attempts.indexOf(attempt); diff --git a/src/server/request-metrics.ts b/src/server/request-metrics.ts index f678b6565ba..cc3b89db977 100644 --- a/src/server/request-metrics.ts +++ b/src/server/request-metrics.ts @@ -1,6 +1,10 @@ import type { ResponsesTerminalStatus } from "../bridge"; import type { AttemptRecoveryKind } from "../usage/log"; -import { type RequestFailureCause, causeForRecoveryKind } from "../lib/request-failure-model"; +import { + REQUEST_FAILURE_CAUSES, + type RequestFailureCause, + causeForRecoveryKind, +} from "../lib/request-failure-model"; import { REQUEST_OUTCOME_CLASSES, classifyRequestOutcome, @@ -40,6 +44,18 @@ export const REQUEST_METRICS_RECOVERY_CLASSES = Object.freeze([ export const REQUEST_DURATION_BUCKETS_SECONDS = Object.freeze([0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60] as const); export const REQUEST_TTFT_BUCKETS_SECONDS = Object.freeze([0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30] as const); +/** + * The failure-cause label set IS the shared dictionary, for the same reason the result label set + * is the shared outcome vocabulary: a restated copy is what let two surfaces drift into + * disagreeing about the same request. + * + * It labels a COUNTER and never a histogram. Fifteen causes across four protocols is sixty + * series, fixed for the lifetime of the roster, and every value comes from a frozen list, so no + * user, model, account or request identifier can reach a series name. A histogram labelled by + * cause would multiply that by its bucket count for no question anyone asks. + */ +export const REQUEST_METRICS_FAILURE_CAUSES = REQUEST_FAILURE_CAUSES; + export type RequestMetricsProtocol = typeof REQUEST_METRICS_PROTOCOLS[number]; export type RequestMetricsResult = RequestOutcomeClass; export type RequestMetricsRecoveryClass = typeof REQUEST_METRICS_RECOVERY_CLASSES[number]; @@ -56,6 +72,11 @@ export interface RequestMetricFinalFact { recoveryKinds: readonly AttemptRecoveryKind[]; }>; spendSends?: number; + /** + * Why this request failed, as the recorder derived it. Absent when it did not fail, which is + * why the counter below cannot be reconstructed by subtracting completions from totals. + */ + failureCause?: RequestFailureCause; } export interface RequestMetricsRecorder { @@ -79,6 +100,7 @@ interface HistogramCell { const protocolCell = (value: RequestMetricsProtocol): number => REQUEST_METRICS_PROTOCOLS.indexOf(value); const resultCell = (value: RequestMetricsResult): number => REQUEST_METRICS_RESULTS.indexOf(value); const recoveryCell = (value: RequestMetricsRecoveryClass): number => REQUEST_METRICS_RECOVERY_CLASSES.indexOf(value); +const failureCauseCell = (value: RequestFailureCause): number => REQUEST_METRICS_FAILURE_CAUSES.indexOf(value); function matrix(rows: number, columns: number): number[][] { return Array.from({ length: rows }, () => Array.from({ length: columns }, () => 0)); @@ -167,6 +189,7 @@ export function createRequestMetricsOwner( let logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length); let physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0); let recoveries = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RECOVERY_CLASSES.length); + let failureCauses = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_FAILURE_CAUSES.length); let durations = histograms(REQUEST_DURATION_BUCKETS_SECONDS); let ttft = histograms(REQUEST_TTFT_BUCKETS_SECONDS); let missingTtft = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length); @@ -187,6 +210,13 @@ export function createRequestMetricsOwner( ), 0); physicalSends[protocolIndex]! += sends; + // Counted from the cause the recorder derived, not re-derived here. Two derivations of one + // answer is the disagreement this batch exists to remove, and the recorder is the only + // place that sees the transport facts a cause needs. + if (fact.failureCause !== undefined) { + failureCauses[protocolIndex]![failureCauseCell(fact.failureCause)]! += 1; + } + for (const attempt of attempts ?? []) { for (const kind of new Set(attempt.recoveryKinds)) { recoveries[protocolIndex]![recoveryCell(recoveryClass(kind))]! += 1; @@ -227,6 +257,15 @@ export function createRequestMetricsOwner( lines.push(`opencodex_recoveries_total{protocol="${protocol}",recovery="${recovery}"} ${recoveries[protocolCell(protocol)]![recoveryCell(recovery)]}`); } } + lines.push( + "# HELP opencodex_request_failures_total Finalized logical requests that did not deliver an answer, by derived cause.", + "# TYPE opencodex_request_failures_total counter", + ); + for (const protocol of REQUEST_METRICS_PROTOCOLS) { + for (const cause of REQUEST_METRICS_FAILURE_CAUSES) { + lines.push(`opencodex_request_failures_total{protocol="${protocol}",cause="${cause}"} ${failureCauses[protocolCell(protocol)]![failureCauseCell(cause)]}`); + } + } appendHistogram(lines, "opencodex_request_duration_seconds", "Finalized logical request duration in seconds.", durations, REQUEST_DURATION_BUCKETS_SECONDS); appendHistogram(lines, "opencodex_ttft_seconds", "Observed time to first output in seconds.", ttft, REQUEST_TTFT_BUCKETS_SECONDS); lines.push( @@ -250,6 +289,7 @@ export function createRequestMetricsOwner( logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length); physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0); recoveries = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RECOVERY_CLASSES.length); + failureCauses = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_FAILURE_CAUSES.length); durations = histograms(REQUEST_DURATION_BUCKETS_SECONDS); ttft = histograms(REQUEST_TTFT_BUCKETS_SECONDS); missingTtft = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length); diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index 46d70186289..e6834c7e941 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -64,6 +64,7 @@ import { recordKeyAttemptUsage, } from "../request-log"; import type { AttemptRecoveryKind } from "../../usage/log"; +import { bindAttemptDeliveryRecorder } from "../../usage/attempt-delivery"; import { resolvePassiveRouteSubjectId } from "../passive-route-linker"; /** Owns live credential selection and adapter bindings for one request. */ @@ -298,15 +299,25 @@ export async function prepareResponsesTransport( recordKeyAttemptUsage(logCtx, event.usage); } }; + // Counted at the one seam every adapter parse passes, and counted for EVERY event rather + // than only usage-bearing ones: the number this pairs with is the frame count the client + // transport relayed, and a difference between the two is the loss signal (#3983). Reading + // the current attempt through logCtx rather than capturing one keeps the count with the + // attempt that is live when the event arrives, across a mid-request attempt rotation. + const delivery = bindAttemptDeliveryRecorder(translatorBudget, () => logCtx.activeAttempt); + const observeEvent = (event: AdapterEvent, response: object): void => { + delivery.noteAdapterEvent(); + observeUsage(event, response); + }; const parseStream = resolved.parseStream.bind(resolved); resolved.parseStream = async function* (...args) { - for await (const event of parseStream(...args)) { observeUsage(event, args[0]); yield event; } + for await (const event of parseStream(...args)) { observeEvent(event, args[0]); yield event; } }; if (resolved.parseResponse) { const parseResponse = resolved.parseResponse.bind(resolved); resolved.parseResponse = async (...args) => { const events = await parseResponse(...args); - events.forEach(event => observeUsage(event, args[0])); + events.forEach(event => observeEvent(event, args[0])); return events; }; } @@ -321,7 +332,7 @@ export async function prepareResponsesTransport( const runTurn = resolved.runTurn.bind(resolved); rawRunTurns.set(resolved, (requestParsed, incoming, emit) => { const response = {}; - return runTurn(requestParsed, incoming, event => { observeUsage(event, response); emit(event); }); + return runTurn(requestParsed, incoming, event => { observeEvent(event, response); emit(event); }); }); resolved.runTurn = (requestParsed, incoming, emit) => runSelectedTurn(resolved, requestParsed, incoming, emit); } diff --git a/src/server/usage-ledger-retention.ts b/src/server/usage-ledger-retention.ts new file mode 100644 index 00000000000..46b5d676f04 --- /dev/null +++ b/src/server/usage-ledger-retention.ts @@ -0,0 +1,73 @@ +/** + * Runtime owner for the opt-in usage-ledger size limit (#5063). + * + * The compactor in `src/usage/ledger-retention.ts` knows how to publish a smaller ledger safely. + * This is the part that decides when, and -- the half #5063 was missing -- what has to be + * discarded afterwards. + * + * Deleting rows from usage.jsonl invalidates three readers that do not watch the file: the + * 2,000-entry Logs ring, which otherwise keeps serving rows the ledger no longer has; the + * retained usage aggregate and failure projection, whose checkpoints now point past a boundary + * that moved; and the request-history index, whose source identity has changed. A compaction + * that skips any of them makes the dashboard disagree with the ledger, which is the disagreement + * this batch exists to remove. + */ +import { currentUsageLogRevision, setUsageLedgerAppendHook } from "../usage/log"; +import { enforceUsageLedgerSizeLimit } from "../usage/ledger-retention"; +import { discardRetainedFailureProjection } from "../usage/failure-projection-cache"; +import { rehydrateRequestLogsAfterLedgerReplacement } from "./request-log"; +import type { UsageLedgerRetentionStatus } from "../usage/retention-contract"; + +let configuredMaxBytes: number | undefined; +let enforcing = false; + +function invalidateLedgerReaders(): void { + // Ordered cheapest-first, and each guarded on its own: a projection that fails to discard + // must not stop the ring from being rebuilt, because the ring is the surface an operator is + // looking at while this happens. + try { discardRetainedFailureProjection(); } catch { /* rebuildable by construction */ } + void (async () => { + try { + const { discardRetainedUsageAggregate } = await import("./management/usage-aggregate-cache"); + discardRetainedUsageAggregate(); + } catch { /* rebuildable by construction */ } + try { + const { closeRequestHistoryIndex } = await import("../routing/history/indexer"); + closeRequestHistoryIndex(); + } catch { /* the index rebuilds from its own source-identity contract */ } + })(); + try { rehydrateRequestLogsAfterLedgerReplacement(); } catch { /* the ring refills as rows arrive */ } +} + +function enforceNow(): void { + // Re-entrancy guard, not a lock. The compaction itself runs inside the append call stack, and + // its own publication writes nothing through appendUsageEntry -- this exists so a future + // caller on that path cannot start a second pass over a file the first one is replacing. + if (enforcing) return; + enforcing = true; + try { + const result = enforceUsageLedgerSizeLimit(configuredMaxBytes); + if (result.kind === "replaced") invalidateLedgerReaders(); + } catch (error) { + // Never fail a request because history could not be trimmed. The limit is not enforced and + // says so; the next append tries again from a fresh revision. + console.warn( + `[usage-retention] could not enforce the usage ledger size limit: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + enforcing = false; + } +} + +/** Install or update the policy. `undefined` removes the hook entirely. */ +export function setUsageLedgerRetention(maxBytes: number | undefined): void { + configuredMaxBytes = maxBytes; + setUsageLedgerAppendHook(maxBytes === undefined ? null : enforceNow); +} + +export function usageLedgerRetentionStatus(): UsageLedgerRetentionStatus { + return { + ...(configuredMaxBytes !== undefined ? { maxBytes: configuredMaxBytes } : {}), + currentBytes: currentUsageLogRevision()?.size ?? 0, + }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 38b29fe8bb5..6f73b99cd42 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -824,6 +824,13 @@ export interface OcxConfig { * See `src/storage/policy.ts`. */ storageCleanupPolicy?: StorageCleanupPolicy; + /** + * Opt-in ceiling in bytes for `usage.jsonl`. Absent means the ledger grows without limit, + * which stays the default: history an operator did not ask to delete is not deleted. Values + * below the documented floor are treated as unset rather than enforced, because a ceiling + * smaller than a row cannot be met without emptying the file. + */ + usageLedgerMaxBytes?: number; /** Generated API keys for external access to the proxy's /v1/responses endpoint. */ apiKeys?: OcxApiKeyEntry[]; /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */ diff --git a/src/usage/attempt-delivery.ts b/src/usage/attempt-delivery.ts new file mode 100644 index 00000000000..c1c011747f8 --- /dev/null +++ b/src/usage/attempt-delivery.ts @@ -0,0 +1,198 @@ +/** + * Counting what an attempt delivered, without recording what it said. + * + * The recorder below is bound to a request-scoped object and reaches the CURRENT attempt through + * a callback rather than holding one. An attempt can be rotated mid-request -- a key-account + * change seals the old one and starts a fresh one -- and a recorder holding a reference would + * keep crediting frames to an attempt that had already been finalized and snapshotted. + * + * Nothing here reads a payload's content. `semanticBytes` is a length; the event classification + * reads only a frame's type name and an item's type name, both of which are protocol constants. + */ +import type { AttemptDeliverySummary } from "./telemetry-contract"; + +export interface AttemptDeliveryTarget { + deliverySummary?: AttemptDeliverySummary; +} + +export interface RelayedEventObservation { + semanticBytes?: number; + sideEffect?: boolean; + terminal?: boolean; +} + +export interface AttemptDeliveryRecorder { + noteAdapterEvent(): void; + noteRelayedEvent(observation?: RelayedEventObservation): void; + noteBufferedDelivery(body: Record): void; +} + +export function createAttemptDeliverySummary(): AttemptDeliverySummary { + return { adapterEvents: 0, relayedEvents: 0, semanticBytes: 0, sideEffectEvents: 0, terminalEvents: 0 }; +} + +/** + * Saturating addition. + * + * A counter that wraps or drifts into a non-integer is worse than one that stops: the row would + * be dropped by the normalizer and the whole summary lost. A long-lived stream that somehow + * reaches the safe-integer ceiling keeps a readable, if pinned, number. + */ +function bump(current: number, by: number): number { + if (!Number.isFinite(by) || by <= 0) return current; + return Math.min(Number.MAX_SAFE_INTEGER, current + Math.floor(by)); +} + +const SEMANTIC_DELTA_EVENTS: ReadonlySet = new Set([ + "response.output_text.delta", + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", + "response.function_call_arguments.delta", + "response.custom_tool_call_input.delta", +]); + +const TERMINAL_EVENTS: ReadonlySet = new Set([ + "response.completed", + "response.incomplete", + "response.failed", +]); + +const SIDE_EFFECT_ITEM_TYPES: ReadonlySet = new Set([ + "function_call", + "custom_tool_call", + "web_search_call", +]); + +/** + * What one relayed frame contributes, read from its type name alone. + * + * A side effect is counted when the item STARTS, not on its argument fragments and not again on + * the matching done frame, so one tool call is one effect however many deltas carried its + * arguments. + */ +export function classifyRelayedResponseEvent( + name: string, + data: Record, +): RelayedEventObservation { + const observation: RelayedEventObservation = {}; + if (SEMANTIC_DELTA_EVENTS.has(name) && typeof data.delta === "string") { + observation.semanticBytes = Buffer.byteLength(data.delta, "utf8"); + } + if (name === "response.output_item.added") { + const item = data.item; + const type = item !== null && typeof item === "object" + ? (item as Record).type + : undefined; + if (typeof type === "string" && SIDE_EFFECT_ITEM_TYPES.has(type)) observation.sideEffect = true; + } + if (TERMINAL_EVENTS.has(name)) observation.terminal = true; + return observation; +} + +/** + * What one buffered response body delivered. + * + * A non-streaming turn has no frames: the whole answer reaches the client as one JSON body. Read + * naively that looks like total relay loss -- adapter events counted, nothing relayed -- which is + * precisely the signal these counters exist to raise, so a buffered response would raise it on + * every request and make it worthless. Everything the adapter produced DID reach the client here; + * it arrived in one piece. So the relayed total is set to the adapter total rather than left at + * zero, and the semantic facts are read from the body that was built. + * + * Fields are read defensively and by name. Keying this on the adapter event union would make a + * member added later a merge-time exhaustiveness failure in a counter that does not need one. + */ +function observeBufferedBody(body: Record): { semanticBytes: number; sideEffects: number } { + const output = Array.isArray(body.output) ? body.output : []; + let semanticBytes = 0; + let sideEffects = 0; + for (const entry of output) { + if (entry === null || typeof entry !== "object") continue; + const item = entry as Record; + if (typeof item.type === "string" && SIDE_EFFECT_ITEM_TYPES.has(item.type)) sideEffects += 1; + if (typeof item.arguments === "string") semanticBytes += Buffer.byteLength(item.arguments, "utf8"); + const content = Array.isArray(item.content) ? item.content : []; + for (const part of content) { + if (part === null || typeof part !== "object") continue; + const text = (part as Record).text; + if (typeof text === "string") semanticBytes += Buffer.byteLength(text, "utf8"); + } + } + return { semanticBytes, sideEffects }; +} + +const recordersByScope = new WeakMap(); + +/** + * Bind a recorder to a request-scoped object. + * + * The scope is the request's translator budget, which every bridge on the delivery path already + * receives. Reusing it avoids threading a new parameter through six call sites where any one of + * them silently defaulting would leave a transport uncounted -- the failure mode that made + * `locallyAnswered` travel on the attempt instead of as an argument. + */ +export function bindAttemptDeliveryRecorder( + scope: object, + currentAttempt: () => AttemptDeliveryTarget | undefined, +): AttemptDeliveryRecorder { + const summaryFor = (): AttemptDeliverySummary | undefined => { + const attempt = currentAttempt(); + if (!attempt) return undefined; + return attempt.deliverySummary ??= createAttemptDeliverySummary(); + }; + const recorder: AttemptDeliveryRecorder = { + noteAdapterEvent(): void { + const summary = summaryFor(); + if (summary) summary.adapterEvents = bump(summary.adapterEvents, 1); + }, + noteRelayedEvent(observation): void { + const summary = summaryFor(); + if (!summary) return; + summary.relayedEvents = bump(summary.relayedEvents, 1); + if (observation?.semanticBytes) summary.semanticBytes = bump(summary.semanticBytes, observation.semanticBytes); + if (observation?.sideEffect) summary.sideEffectEvents = bump(summary.sideEffectEvents, 1); + if (observation?.terminal) summary.terminalEvents = bump(summary.terminalEvents, 1); + }, + noteBufferedDelivery(body): void { + const summary = summaryFor(); + if (!summary) return; + const observed = observeBufferedBody(body); + summary.relayedEvents = Math.max(summary.relayedEvents, summary.adapterEvents); + summary.semanticBytes = bump(summary.semanticBytes, observed.semanticBytes); + summary.sideEffectEvents = bump(summary.sideEffectEvents, observed.sideEffects); + summary.terminalEvents = bump(summary.terminalEvents, 1); + }, + }; + recordersByScope.set(scope, recorder); + return recorder; +} + +export function attemptDeliveryRecorder(scope: object | undefined): AttemptDeliveryRecorder | undefined { + return scope ? recordersByScope.get(scope) : undefined; +} + +/** + * A persisted summary is trusted only when all five counts are non-negative safe integers. + * + * The whole record is dropped rather than repaired: a partially trusted count is a number an + * operator would compare against another number, and half a summary is how a loss signal turns + * into a false one. + */ +export function normalizeAttemptDeliverySummary(value: unknown): AttemptDeliverySummary | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + const counts = createAttemptDeliverySummary(); + for (const key of Object.keys(counts) as Array) { + const count = raw[key]; + if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) return undefined; + counts[key] = count; + } + return counts; +} + +/** A detached copy, so a snapshotted attempt cannot keep counting after it was finalized. */ +export function cloneAttemptDeliverySummary( + summary: AttemptDeliverySummary | undefined, +): AttemptDeliverySummary | undefined { + return summary ? { ...summary } : undefined; +} diff --git a/src/usage/failure-fingerprint.ts b/src/usage/failure-fingerprint.ts new file mode 100644 index 00000000000..ea9524b1c19 --- /dev/null +++ b/src/usage/failure-fingerprint.ts @@ -0,0 +1,118 @@ +/** + * A versioned, content-free identity for one kind of request failure (#3748). + * + * #3748 proposed a second SQLite store under src/telemetry/ holding a free-text `signature` + * masked by regular expressions. Both halves are replaced here. The store is replaced by a + * projection rebuilt from usage.jsonl, and the masked signature is replaced by a tuple of closed + * roster members -- because a regular expression can only assert that it removed what it matched, + * while a tuple whose every slot is a member of a frozen list has nothing to remove. + * + * That is the whole design: the input type cannot express a provider alias, a model, an account, + * an error message, a prompt, a request id or a timestamp, so no amount of upstream text can + * reach a fingerprint. + */ +import { createHash } from "node:crypto"; +import type { RequestFailureCause } from "../lib/request-failure-model"; +import type { RequestCloseReason, RequestTerminalStatus } from "./request-outcome"; +import type { PersistedUsageEntry } from "./log"; + +/** + * The tuple layout and its meaning are one contract. + * + * Adding, removing, reordering or reinterpreting a position changes what a stored fingerprint + * means, so any of those requires incrementing this. Tests import it rather than writing `1`, + * so a bump cannot be silently contradicted by a test that still expects the old prefix. + */ +export const FAILURE_FINGERPRINT_VERSION = 1 as const; + +/** + * The status reduced to its class. + * + * The exact code is not in the tuple: a 502 and a 503 that both failed for `upstream-fault` are + * one problem to an operator, and keeping the code would split every group by whichever number + * an origin happened to send. + */ +export const FAILURE_STATUS_CLASSES = Object.freeze([ + "1xx", "2xx", "3xx", "4xx", "5xx", "unknown", +] as const); + +export type FailureStatusClass = typeof FAILURE_STATUS_CLASSES[number]; + +export type FailureFingerprint = `v${typeof FAILURE_FINGERPRINT_VERSION}:${string}`; + +/** + * Everything a fingerprint is allowed to read, and nothing else. + * + * Every slot is either a member of a frozen roster or `null`. `providerClass` is the one field + * that starts life as free text: the durable `provider` is a name the user chose, so it is + * resolved against the provider registry first and becomes `null` when it is not a registry + * member. A configured alias therefore cannot reach the key whatever it was named. + */ +export interface FailureFingerprintFacts { + readonly cause: RequestFailureCause; + readonly statusClass: FailureStatusClass; + readonly providerClass: string | null; + readonly inboundProtocol: NonNullable | null; + readonly terminalStatus: RequestTerminalStatus | null; + readonly closeReason: RequestCloseReason | null; + readonly transportPhase: NonNullable | null; + readonly terminalSource: NonNullable | null; +} + +/** + * Fixed positions, with every absent fact written as an explicit `null`. + * + * Omitting an absent field, or joining the present ones with a delimiter, would let two + * different failures collide: `[a, null, b]` and `[a, b]` are the same string once the nulls + * are dropped. A fixed-arity tuple cannot collide that way, which is why the shape is a tuple + * rather than an object with optional keys. + */ +export type FailureFingerprintTuple = readonly [ + version: typeof FAILURE_FINGERPRINT_VERSION, + cause: FailureFingerprintFacts["cause"], + statusClass: FailureFingerprintFacts["statusClass"], + providerClass: FailureFingerprintFacts["providerClass"], + inboundProtocol: FailureFingerprintFacts["inboundProtocol"], + terminalStatus: FailureFingerprintFacts["terminalStatus"], + closeReason: FailureFingerprintFacts["closeReason"], + transportPhase: FailureFingerprintFacts["transportPhase"], + terminalSource: FailureFingerprintFacts["terminalSource"], +]; + +export function failureStatusClass(status: unknown): FailureStatusClass { + if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) { + return "unknown"; + } + const index = Math.floor(status / 100) - 1; + return FAILURE_STATUS_CLASSES[index] ?? "unknown"; +} + +export function canonicalFailureFingerprintTuple( + facts: FailureFingerprintFacts, +): FailureFingerprintTuple { + return [ + FAILURE_FINGERPRINT_VERSION, + facts.cause, + facts.statusClass, + facts.providerClass, + facts.inboundProtocol, + facts.terminalStatus, + facts.closeReason, + facts.transportPhase, + facts.terminalSource, + ]; +} + +/** + * The version travels in the value, not only in the hashed input. + * + * Both matter and for different reasons: hashing it means two versions of the same failure never + * collide, and prefixing it means a reader holding an old fingerprint can tell that it is old + * instead of concluding the failure stopped happening. + */ +export function computeFailureFingerprint(facts: FailureFingerprintFacts): FailureFingerprint { + const digest = createHash("sha256") + .update(JSON.stringify(canonicalFailureFingerprintTuple(facts))) + .digest("hex"); + return `v${FAILURE_FINGERPRINT_VERSION}:${digest}`; +} diff --git a/src/usage/failure-projection-cache.ts b/src/usage/failure-projection-cache.ts new file mode 100644 index 00000000000..953f292fe52 --- /dev/null +++ b/src/usage/failure-projection-cache.ts @@ -0,0 +1,174 @@ +/** + * The scan owner for the failure projection: one checkpointed pass over usage.jsonl. + * + * Shaped after `src/server/management/usage-aggregate-cache.ts`, which solved the same problem + * for the usage summary. That similarity is deliberate -- the correctness here is entirely in + * the checkpoint discipline, and two subtly different versions of it is how a projection quietly + * extends stale groups across a file that was replaced under it. + * + * Every bound this projection obeys belongs to the scanner it calls, not to itself: the 1 MiB + * row ceiling, the 1 MiB chunk, the cooperative yield, the opened-EOF snapshot boundary and the + * path/device/inode/birthtime identity with its 64 KiB boundary digest. A projection with its + * own limits would be a second storage policy, which is what this lane exists to avoid. + */ +import { + currentUsageLogRevision, + usageLogIdentityKey, + usageLogRevisionKey, + type UsageLogRevision, +} from "./log"; +import { + scanUsageLedgerCooperatively, + UsageLedgerRebuildRequiredError, +} from "./ledger-scanner"; +import { + createFailureProjectionAccumulator, + type FailureProjectionAccumulator, + type FailureProjectionSnapshot, +} from "./failure-projection"; + +export type FailureProjectionUpdate = "unchanged" | "append" | "rebuild"; + +export interface FailureProjectionResult extends FailureProjectionSnapshot { + update: FailureProjectionUpdate; + /** True once any row was skipped for exceeding the scanner's row ceiling. Sticky. */ + historyIncomplete: boolean; +} + +interface RetainedProjection { + accumulator: FailureProjectionAccumulator; + historyIncomplete: boolean; + revision: UsageLogRevision | null; + identityKey: string; + revisionKey: string; + processedThroughBytes: number; + processedThroughDigest: string; +} + +const MAX_REBUILD_ATTEMPTS = 2; +let retained: RetainedProjection | null = null; +let inFlight: Promise | null = null; + +function resultFrom(state: RetainedProjection, update: FailureProjectionUpdate): FailureProjectionResult { + return { ...state.accumulator.snapshot(), update, historyIncomplete: state.historyIncomplete }; +} + +function retain( + accumulator: FailureProjectionAccumulator, + scan: Awaited>, + historyIncomplete: boolean, +): RetainedProjection { + return { + accumulator, + historyIncomplete: historyIncomplete || scan.oversizedRows > 0, + revision: scan.revision, + identityKey: usageLogIdentityKey(scan.revision), + revisionKey: usageLogRevisionKey(scan.revision), + processedThroughBytes: scan.processedThroughBytes, + processedThroughDigest: scan.processedThroughDigest, + }; +} + +async function rebuild(signal: AbortSignal | undefined): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_REBUILD_ATTEMPTS; attempt += 1) { + const accumulator = createFailureProjectionAccumulator(); + try { + const scan = await scanUsageLedgerCooperatively({ + ...(signal ? { signal } : {}), + onEntry: entry => accumulator.add(entry), + }); + retained = retain(accumulator, scan, false); + return resultFrom(retained, "rebuild"); + } catch (error) { + lastError = error; + if (!(error instanceof UsageLedgerRebuildRequiredError) || attempt + 1 >= MAX_REBUILD_ATTEMPTS) throw error; + } + } + throw lastError ?? new Error("failure projection rebuild did not settle"); +} + +/** + * Whether the observed ledger can still be read as an append onto the retained state. + * + * A same-size file whose revision metadata moved is a replacement or an in-place edit, not an + * append. Treating it as one would extend groups built from rows that no longer exist, so it + * forces a rebuild even though the byte count is unchanged. + */ +function requiresRebuild(state: RetainedProjection, observed: UsageLogRevision | null): boolean { + if (state.identityKey !== usageLogIdentityKey(observed)) return true; + if (!state.revision || !observed) return state.revision !== observed; + if (observed.size < state.revision.size) return true; + return observed.size === state.revision.size && usageLogRevisionKey(observed) !== state.revisionKey; +} + +async function append( + state: RetainedProjection, + signal: AbortSignal | undefined, +): Promise { + // Clone first and publish only after the scanner verifies the captured suffix, so a mutation + // discovered mid-scan leaves the retained state exactly as it was. + const candidate = state.accumulator.clone(); + try { + const scan = await scanUsageLedgerCooperatively({ + ...(signal ? { signal } : {}), + startAtBytes: state.processedThroughBytes, + expectedIdentityKey: state.identityKey, + expectedProcessedThroughDigest: state.processedThroughDigest, + onEntry: entry => candidate.add(entry), + }); + retained = retain(candidate, scan, state.historyIncomplete); + return resultFrom(retained, "append"); + } catch (error) { + if (retained === state) retained = null; + if (error instanceof UsageLedgerRebuildRequiredError) return rebuild(signal); + throw error; + } +} + +async function refresh(signal: AbortSignal | undefined): Promise { + const state = retained; + if (!state) return rebuild(signal); + const observed = currentUsageLogRevision(); + if (requiresRebuild(state, observed)) return rebuild(signal); + if (observed && state.revision && observed.size === state.revision.size) { + return resultFrom(state, "unchanged"); + } + return append(state, signal); +} + +/** + * The current grouping, refreshed from the ledger. + * + * Single-flighted: two concurrent readers would otherwise run two scans of the same file and + * one of them would publish over the other's checkpoint. + */ +export async function getFailureProjection( + options: { signal?: AbortSignal } = {}, +): Promise { + if (inFlight) return inFlight; + const flight = refresh(options.signal).finally(() => { + if (inFlight === flight) inFlight = null; + }); + inFlight = flight; + return flight; +} + +/** + * Drop the retained projection. + * + * Safe at any time and for any reason: it is rebuildable from the ledger by construction, which + * is the property that lets memory pressure discard the whole thing rather than prune individual + * groups. Pruning groups would make this projection a retention policy of its own. + */ +export function discardRetainedFailureProjection(): number { + const count = retained?.accumulator.groupCount ?? 0; + retained = null; + return count; +} + +/** Test-only process-state reset for isolated harnesses. */ +export function resetFailureProjectionCacheForTests(): void { + retained = null; + inFlight = null; +} diff --git a/src/usage/failure-projection.ts b/src/usage/failure-projection.ts new file mode 100644 index 00000000000..f235eac3628 --- /dev/null +++ b/src/usage/failure-projection.ts @@ -0,0 +1,174 @@ +/** + * Failures grouped by what they have in common, rebuilt from the canonical ledger. + * + * This is the derived form of #3748. The original built a second durable store; this holds only + * a count and two timestamps per group, and every one of them falls out of a scan of + * usage.jsonl. Delete a row from the ledger and it leaves this projection on the next rebuild, + * which is what it means for retention to have one owner rather than four. + * + * What it deliberately does NOT hold: the occurrence list the original retained (a second copy + * of history with its own retention policy), and the mutable monitoring/dispatched/fixed/ignored + * remediation status with its free-text notes. Those are operator state, not event history: they + * cannot be reconstructed from immutable request rows, so presenting them as a derived ledger + * would be presenting a claim this projection cannot make. They need their own owner if they are + * wanted, keyed by the fingerprint below. + */ +import { getProviderRegistryEntry } from "../providers/registry"; +import { baseProviderLabel } from "../providers/label"; +import { + computeFailureFingerprint, + failureStatusClass, + FAILURE_FINGERPRINT_VERSION, + type FailureFingerprint, + type FailureFingerprintFacts, +} from "./failure-fingerprint"; +import { + classifyRequestOutcome, + isRequestCloseReason, + isRequestTerminalStatus, +} from "./request-outcome"; +import { + isKnownInboundProtocol, + isKnownRequestFailureCause, + isKnownTerminalSource, + isKnownTransportPhase, + type PersistedUsageEntry, +} from "./log"; + +/** + * The configured provider name reduced to a registry member, or null. + * + * The durable `provider` is whatever the user named their provider entry, so it is open text and + * cannot enter a key that promises to carry no content. Resolving it against the registry makes + * the value closed by construction: either it is one of the ids this build ships, or it is + * nothing. A user who names a provider after themselves groups under `null`, which is the + * correct answer -- the projection does not know which provider it is. + */ +export function failureProviderClass(provider: string): string | null { + return getProviderRegistryEntry(baseProviderLabel(provider))?.id ?? null; +} + +export interface FailureProjectionGroup extends FailureFingerprintFacts { + fingerprint: FailureFingerprint; + firstSeen: number; + lastSeen: number; + count: number; +} + +export interface FailureProjectionSnapshot { + fingerprintVersion: typeof FAILURE_FINGERPRINT_VERSION; + groups: readonly FailureProjectionGroup[]; + /** + * Failed rows written before the recorder stored a cause. Counted rather than bucketed under + * an invented "unknown" cause, because a group an operator cannot act on is worse than a + * number that says how much history predates the field. + */ + unattributedFailures: number; + /** Failed rows whose timestamp is not a finite number, so they cannot date a group. */ + invalidTimestampFailures: number; +} + +export interface FailureProjectionAccumulator { + add(entry: PersistedUsageEntry): void; + clone(): FailureProjectionAccumulator; + snapshot(): FailureProjectionSnapshot; + readonly groupCount: number; +} + +interface MutableGroup extends FailureFingerprintFacts { + fingerprint: FailureFingerprint; + firstSeen: number; + lastSeen: number; + count: number; +} + +function factsFor(entry: PersistedUsageEntry): FailureFingerprintFacts | null { + if (!isKnownRequestFailureCause(entry.failureCause)) return null; + return { + cause: entry.failureCause, + statusClass: failureStatusClass(entry.status), + providerClass: failureProviderClass(entry.provider), + inboundProtocol: isKnownInboundProtocol(entry.inboundProtocol) ? entry.inboundProtocol : null, + // Validated rather than copied. This is the one tuple slot whose durable type is a plain + // string, and it is assembled from an upstream terminal frame, so an unvalidated value is + // the single way upstream-controlled text could reach a grouping key. + terminalStatus: isRequestTerminalStatus(entry.terminalStatus) ? entry.terminalStatus : null, + closeReason: isRequestCloseReason(entry.closeReason) ? entry.closeReason : null, + transportPhase: isKnownTransportPhase(entry.transportPhase) ? entry.transportPhase : null, + terminalSource: isKnownTerminalSource(entry.terminalSource) ? entry.terminalSource : null, + }; +} + +function createFrom(groups: Map, counters: { + unattributed: number; + invalidTimestamp: number; +}): FailureProjectionAccumulator { + let unattributedFailures = counters.unattributed; + let invalidTimestampFailures = counters.invalidTimestamp; + + return { + add(entry: PersistedUsageEntry): void { + // The shared classifier decides what a failure is, so this projection and the exporter + // agree on which rows are in scope. An incomplete turn is not here: it has no cause. + if (classifyRequestOutcome(entry) !== "failed") return; + const facts = factsFor(entry); + if (facts === null) { + unattributedFailures += 1; + return; + } + if (typeof entry.timestamp !== "number" || !Number.isFinite(entry.timestamp)) { + invalidTimestampFailures += 1; + return; + } + const fingerprint = computeFailureFingerprint(facts); + const existing = groups.get(fingerprint); + if (existing === undefined) { + groups.set(fingerprint, { + ...facts, + fingerprint, + firstSeen: entry.timestamp, + lastSeen: entry.timestamp, + count: 1, + }); + return; + } + // Min and max rather than first-and-last-written: a ledger is append-ordered in practice + // but nothing in the format promises it, and a projection that assumed order would report + // a first-seen later than its last-seen for a hand-merged file. + existing.firstSeen = Math.min(existing.firstSeen, entry.timestamp); + existing.lastSeen = Math.max(existing.lastSeen, entry.timestamp); + existing.count += 1; + }, + + clone(): FailureProjectionAccumulator { + const copy = new Map(); + for (const [key, group] of groups) copy.set(key, { ...group }); + return createFrom(copy, { + unattributed: unattributedFailures, + invalidTimestamp: invalidTimestampFailures, + }); + }, + + snapshot(): FailureProjectionSnapshot { + // Most recent first, then by fingerprint, so two runs over the same ledger produce the + // same order. A tie broken by insertion order would depend on scan chunking. + const ordered = [...groups.values()] + .map(group => ({ ...group })) + .sort((a, b) => b.lastSeen - a.lastSeen || (a.fingerprint < b.fingerprint ? -1 : a.fingerprint > b.fingerprint ? 1 : 0)); + return { + fingerprintVersion: FAILURE_FINGERPRINT_VERSION, + groups: ordered, + unattributedFailures, + invalidTimestampFailures, + }; + }, + + get groupCount(): number { + return groups.size; + }, + }; +} + +export function createFailureProjectionAccumulator(): FailureProjectionAccumulator { + return createFrom(new Map(), { unattributed: 0, invalidTimestamp: 0 }); +} diff --git a/src/usage/ledger-retention.ts b/src/usage/ledger-retention.ts new file mode 100644 index 00000000000..aaaf17bcc5b --- /dev/null +++ b/src/usage/ledger-retention.ts @@ -0,0 +1,160 @@ +/** + * Opt-in size limit for the canonical usage ledger (#5063). + * + * Retention on usage.jsonl is the right architecture -- the alternative is a projection that + * deletes rows the ledger still has, which is a second retention policy. What #5063's version + * could not promise is that a row appended by another writer between its size snapshot and its + * rename survived: it captured a size, copied a suffix, and renamed over whatever was there. + * + * Two things close that here. The append is synchronous and this runs inside the same call + * stack, with no await between the append and the publication, so no in-process append can + * interleave. And `validateBeforeRename` re-opens the target immediately before the rename and + * refuses unless its identity, size and revision metadata are byte-for-byte what was copied -- + * so an append from anywhere else aborts the replacement instead of losing the row. The original + * file and that append both survive; the next append retries from a fresh revision. + * + * What remains outside the contract: a program that ignores the OpenCodex ledger owner entirely + * can still write between the final comparison and the rename. No portable conditional rename + * exists to prevent that, and the honest claim is that the race is closed for every cooperating + * writer and detected up to the last possible moment for anything else. + */ +import { closeSync, fstatSync, openSync, readSync, writeSync } from "node:fs"; +import { atomicWriteFileStreamed } from "../config/atomic-write"; +import { + currentUsageLogRevision, + usageLogIdentityKey, + usageLogPath, + usageLogRevisionKey, + type UsageLogRevision, +} from "./log"; +import { + MIN_USAGE_LEDGER_MAX_BYTES, + USAGE_LEDGER_RETENTION_TARGET_RATIO, +} from "./retention-contract"; + +/** Bounded copy buffer. Matches the ledger scanner's chunk so one storage policy governs both. */ +const COPY_CHUNK_BYTES = 1024 * 1024; + +export class UsageLedgerRevisionChangedError extends Error { + readonly code = "usage_ledger_revision_changed"; + + constructor() { + super("usage ledger changed while its retained span was being published"); + this.name = "UsageLedgerRevisionChangedError"; + } +} + +export type UsageLedgerRetentionResult = + | { kind: "disabled" } + | { kind: "unchanged"; currentBytes: number } + | { kind: "replaced"; currentBytes: number; removedBytes: number } + | { kind: "deferred"; currentBytes: number; reason: "revision-changed" | "no-boundary" }; + +/** + * The first LF at or after `from`, so the retained span starts on a row boundary. + * + * Any nonempty suffix that is not LF-terminated is uncommitted by the scanner's definition, even + * if it happens to parse. Starting anywhere but after an LF would publish half a row as a whole + * one, which is the shape that makes a ledger unreadable rather than merely shorter. + */ +function firstRowBoundary(fd: number, from: number, end: number): number | null { + const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES); + for (let position = from; position < end;) { + const read = readSync(fd, buffer, 0, Math.min(buffer.byteLength, end - position), position); + if (read <= 0) return null; + const index = buffer.subarray(0, read).indexOf(0x0a); + if (index >= 0) return position + index + 1; + position += read; + } + return null; +} + +function copyRange(sourceFd: number, targetFd: number, from: number, to: number): void { + const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES); + for (let position = from; position < to;) { + const read = readSync(sourceFd, buffer, 0, Math.min(buffer.byteLength, to - position), position); + if (read <= 0) throw new Error("usage ledger shrank while its retained span was copied"); + let written = 0; + while (written < read) written += writeSync(targetFd, buffer, written, read - written); + position += read; + } +} + +function sameRevision(a: UsageLogRevision | null, b: UsageLogRevision | null): boolean { + return a !== null && b !== null + && usageLogIdentityKey(a) === usageLogIdentityKey(b) + && usageLogRevisionKey(a) === usageLogRevisionKey(b) + && a.size === b.size; +} + +/** + * Trim the ledger to the newest whole rows when it exceeds `maxBytes`. + * + * Rows are copied BYTE FOR BYTE and never parsed or re-serialized. That is what keeps the + * failure stage and cause, the attempts, the spend record and any field a later build adds + * intact through a compaction: a retention pass that understood the row shape would silently + * drop every field it was written before. + */ +export function enforceUsageLedgerSizeLimit( + maxBytes: number | undefined, + /** + * Runs after the retained span is copied and before the pre-rename check. + * + * A parameter rather than an exported flag, so the only way to reach this window is to be the + * caller. The revision guard below is the one piece of this module that cannot be observed + * from its inputs and outputs, and a contract nothing can drive is a contract nobody has + * checked. + */ + options: { onSpanCopied?: () => void } = {}, +): UsageLedgerRetentionResult { + if (maxBytes === undefined || !Number.isSafeInteger(maxBytes) || maxBytes < MIN_USAGE_LEDGER_MAX_BYTES) { + return { kind: "disabled" }; + } + const captured = currentUsageLogRevision(); + if (!captured || captured.size <= maxBytes) { + return captured ? { kind: "unchanged", currentBytes: captured.size } : { kind: "disabled" }; + } + const path = usageLogPath(); + // Trim below the ceiling rather than to it, so an append does not immediately re-cross the + // line and make every subsequent append pay for a full rewrite. + const target = Math.floor(maxBytes * USAGE_LEDGER_RETENTION_TARGET_RATIO); + let sourceFd: number; + try { + sourceFd = openSync(path, "r"); + } catch { + return { kind: "deferred", currentBytes: captured.size, reason: "revision-changed" }; + } + try { + const opened = fstatSync(sourceFd); + if (Number(opened.size) !== captured.size || Number(opened.ino) !== captured.ino) { + return { kind: "deferred", currentBytes: captured.size, reason: "revision-changed" }; + } + const start = firstRowBoundary(sourceFd, Math.max(0, captured.size - target), captured.size); + // No LF in the retained window means one row is larger than the whole target. Deleting it + // would empty the ledger to satisfy a ceiling it cannot meet, so nothing is done. + if (start === null || start >= captured.size) { + return { kind: "deferred", currentBytes: captured.size, reason: "no-boundary" }; + } + atomicWriteFileStreamed(path, descriptor => { + copyRange(sourceFd, descriptor, start, captured.size); + options.onSpanCopied?.(); + }, { + // The last possible moment. Anything that appended, replaced or rewrote the ledger while + // the copy ran moves size, inode or revision metadata, and the throw leaves both the + // original file and that write exactly as they are. + validateBeforeRename: () => { + if (!sameRevision(currentUsageLogRevision(), captured)) { + throw new UsageLedgerRevisionChangedError(); + } + }, + }); + return { kind: "replaced", currentBytes: captured.size - start, removedBytes: start }; + } catch (error) { + if (error instanceof UsageLedgerRevisionChangedError) { + return { kind: "deferred", currentBytes: captured.size, reason: "revision-changed" }; + } + throw error; + } finally { + closeSync(sourceFd); + } +} diff --git a/src/usage/log.ts b/src/usage/log.ts index 4a0be286bcb..6955db2073a 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -7,6 +7,13 @@ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { sanitizeLogMetadataString } from "../lib/redact"; import { usageDisplayTotalTokens } from "./totals"; +import { normalizeAttemptDeliverySummary } from "./attempt-delivery"; +import { + isRequestCloseReason, + isRequestTerminalStatus, + type RequestCloseReason, + type RequestTerminalStatus, +} from "./request-outcome"; import type { AttemptTierOutcome, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; @@ -15,15 +22,21 @@ import type { CodexWsStageRecord } from "../server/responses/codex-ws-wire"; import { ATTEMPT_RECOVERY_KIND_ROSTER, ATTEMPT_RECOVERY_WITHHELD_ROSTER, + REQUEST_FAILURE_CAUSES, + REQUEST_FAILURE_STAGES, + REQUEST_TRANSPORT_PHASES, type AttemptRecoveryKind, type AttemptRecoveryWithheld, + type AttemptDeliverySummary, + type RequestFailureCause, + type RequestFailureStage, type RequestSpendTotals, } from "./telemetry-contract"; // Re-exported so every existing importer keeps its path. The declarations moved to a leaf the // dashboard can import without pulling node:fs and the config barrel into the browser build. -export { ATTEMPT_RECOVERY_KIND_ROSTER, ATTEMPT_RECOVERY_WITHHELD_ROSTER }; -export type { AttemptRecoveryKind, AttemptRecoveryWithheld, RequestSpendTotals }; +export { ATTEMPT_RECOVERY_KIND_ROSTER, ATTEMPT_RECOVERY_WITHHELD_ROSTER, REQUEST_FAILURE_CAUSES, REQUEST_FAILURE_STAGES }; +export type { AttemptRecoveryKind, AttemptRecoveryWithheld, RequestFailureCause, RequestFailureStage, RequestSpendTotals }; export interface PersistedClaudeCompatibilityLog { decision: "shadow"; @@ -173,6 +186,21 @@ export interface PersistedUsageAttempt { * account identifiers. */ codexWsStage?: CodexWsStageRecord; + /** + * What this attempt delivered, as five bounded counts (#3983). Absent on attempts whose + * transport does not pass through the Responses bridge and on pre-instrumentation rows. + */ + deliverySummary?: AttemptDeliverySummary; + /** + * How far this attempt's exchange got and why it failed, in the shared vocabulary (#2366). + * + * Both values are closed roster members, so the pair can be a metric label and a grouping key + * without a masking pass. Absent on a completed attempt and on every row written before the + * attribution existed. The resend verdict these two imply is NOT stored: it is derived at read + * time, so a stored row can never carry a verdict the current table would no longer reach. + */ + failureStage?: RequestFailureStage; + failureCause?: RequestFailureCause; } /** @@ -290,8 +318,13 @@ export interface PersistedUsageEntry { // Failure diagnostics (devlog/_plan/260716_claudecode_hardening/030): persisted for // status>=400 or non-completed terminals so incidents survive the in-memory ring buffer. errorCode?: string; - terminalStatus?: string; - closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow"; + /** + * Closed, like `closeReason` beside it has always been. It was `string` while it was only + * rendered; it is a grouping-key slot now, and the value is assembled from an upstream + * terminal frame, so an open type here is the one way upstream text could reach that key. + */ + terminalStatus?: RequestTerminalStatus; + closeReason?: RequestCloseReason; /** Already redacted + capped at capture (request-log.ts redactSecretString().slice(0,500)). */ upstreamError?: string; /** Where the terminal/failure was observed; absent on historic rows. */ @@ -318,6 +351,53 @@ export interface PersistedUsageEntry { routeDecision?: RouteDecisionTraceV1; /** Closed Claude protocol codes only; absent on older rows. */ claudeCompatibility?: PersistedClaudeCompatibilityLog; + /** + * How far this request got and why it failed (#2366). Projected from the attempt that ended + * the request so every surface reads the answer off the same row. Absent on a completed + * request and on rows written before the attribution existed. + */ + failureStage?: RequestFailureStage; + failureCause?: RequestFailureCause; +} + +/** + * Attribution for the logical request, projected from the attempt that ended it (#2366). + * + * Carried on the entry as well as the attempt because the three surfaces that have to agree read + * the entry: a projection that had to reach into `attempts` to answer "why did this fail" would + * be reading a different row from the exporter, which is the disagreement the landed terminal + * classifier already removed once. + */ +export interface PersistedRequestFailureAttribution { + failureStage?: RequestFailureStage; + failureCause?: RequestFailureCause; +} + +const KNOWN_REQUEST_FAILURE_STAGES: ReadonlySet = new Set(REQUEST_FAILURE_STAGES); +const KNOWN_REQUEST_FAILURE_CAUSES: ReadonlySet = new Set(REQUEST_FAILURE_CAUSES); + +/** + * Same closed-set discipline as `isKnownTransportPhase`, with the set DERIVED from the roster + * rather than restated. The recovery vocabulary was written twice once -- as a union and as the + * read-back whitelist -- and a member present in only one of them is written to disk and dropped + * on the next read, which loses exactly the field that says why the row failed. + */ +export function isKnownRequestFailureStage(value: unknown): value is RequestFailureStage { + return typeof value === "string" && KNOWN_REQUEST_FAILURE_STAGES.has(value); +} + +export function isKnownRequestFailureCause(value: unknown): value is RequestFailureCause { + return typeof value === "string" && KNOWN_REQUEST_FAILURE_CAUSES.has(value); +} + +/** The stage/cause pair a normalizer keeps, dropping either half that is not a roster member. */ +export function normalizeRequestFailureAttribution( + raw: { failureStage?: unknown; failureCause?: unknown }, +): PersistedRequestFailureAttribution { + return { + ...(isKnownRequestFailureStage(raw.failureStage) ? { failureStage: raw.failureStage } : {}), + ...(isKnownRequestFailureCause(raw.failureCause) ? { failureCause: raw.failureCause } : {}), + }; } const KNOWN_USAGE_SURFACES = new Set>([ @@ -355,12 +435,10 @@ export function isKnownInboundProtocol(value: unknown): value is NonNullable); } -const KNOWN_TRANSPORT_PHASES = new Set>([ - "pre_headers", "mid_stream", "terminal_sse", -]); +const KNOWN_TRANSPORT_PHASES: ReadonlySet = new Set(REQUEST_TRANSPORT_PHASES); export function isKnownTransportPhase(value: unknown): value is NonNullable { - return typeof value === "string" && KNOWN_TRANSPORT_PHASES.has(value as NonNullable); + return typeof value === "string" && KNOWN_TRANSPORT_PHASES.has(value); } const KNOWN_TERMINAL_SOURCES = new Set>([ @@ -587,6 +665,9 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { const codexWsStage = "codexWsStage" in attempt ? normalizeCodexWsStageRecord(attempt.codexWsStage) : undefined; + const deliverySummary = "deliverySummary" in attempt + ? normalizeAttemptDeliverySummary(attempt.deliverySummary) + : undefined; const recoveryKinds = Array.isArray(attempt.recoveryKinds) ? [...new Set(attempt.recoveryKinds.filter( (value): value is AttemptRecoveryKind => typeof value === "string" @@ -655,6 +736,8 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { : {}), ...(tierOutcome ? { tierOutcome } : {}), ...(codexWsStage ? { codexWsStage } : {}), + ...(deliverySummary ? { deliverySummary } : {}), + ...normalizeRequestFailureAttribution(attempt), }; } @@ -826,11 +909,15 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(affinityReason ? { affinityReason } : {}), ...(conversationStateScrub ? { conversationStateScrub } : {}), ...(entry.errorCode ? { errorCode: entry.errorCode } : {}), - ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}), - ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), + // Validated rather than copied on truthiness, like the inbound protocol and transport phase + // above. Harmless while these were only rendered; not harmless once the terminal status is + // a grouping-key slot, because the string is assembled from an upstream frame. + ...(isRequestTerminalStatus(entry.terminalStatus) ? { terminalStatus: entry.terminalStatus } : {}), + ...(isRequestCloseReason(entry.closeReason) ? { closeReason: entry.closeReason } : {}), ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}), ...(routeDecision ? { routeDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), + ...normalizeRequestFailureAttribution(entry), }; } @@ -865,6 +952,20 @@ function ensureUsageLogDir(now: number): void { ensuredUsageLogDir = { path: dir, checkedAt: now }; } +/** + * One owner hook, run after an append lands. + * + * A slot rather than a direct call, because the only consumer -- ledger retention -- reads this + * module's revision helpers, and importing it back here would be a static cycle. The hook runs + * INSIDE the synchronous append call stack on purpose: that is what makes "no in-process append + * can interleave with a compaction" true rather than merely likely. + */ +let afterUsageLedgerAppend: (() => void) | null = null; + +export function setUsageLedgerAppendHook(hook: (() => void) | null): void { + afterUsageLedgerAppend = hook; +} + export function appendUsageEntry(entry: PersistedUsageEntry): void { const line = `${JSON.stringify(normalizeUsageEntry(entry))}\n`; const path = usageLogPath(); @@ -885,10 +986,12 @@ export function appendUsageEntry(entry: PersistedUsageEntry): void { ensuredUsageLogDir = null; ensuredUsageLogFile = null; doAppend(); + afterUsageLedgerAppend?.(); return; } throw error; } + afterUsageLedgerAppend?.(); } export type UsageLogRevision = { diff --git a/src/usage/request-outcome.ts b/src/usage/request-outcome.ts index 9db60aee365..a72f4cb8662 100644 --- a/src/usage/request-outcome.ts +++ b/src/usage/request-outcome.ts @@ -34,6 +34,54 @@ export const REQUEST_OUTCOME_CLASSES = Object.freeze([ export type RequestOutcomeClass = typeof REQUEST_OUTCOME_CLASSES[number]; +/** + * The terminal statuses a Responses turn can settle on. + * + * Derived from the outcome classes rather than restated: a turn reports whether it completed, + * failed or stopped short, and `aborted` is not one of them because the caller leaving is not a + * terminal the origin emits. Deriving it means a fifth outcome class cannot leave this list + * stale, and restating the three would be the same copy that let the recovery roster drift. + */ +export type RequestTerminalStatus = Exclude; + +/** + * The same three members as a runtime list, filtered out of the outcome roster rather than + * typed out again, so the guard below cannot disagree with the type above it. + */ +export const REQUEST_TERMINAL_STATUSES: readonly RequestTerminalStatus[] = Object.freeze( + REQUEST_OUTCOME_CLASSES.filter((value): value is RequestTerminalStatus => value !== "aborted"), +); + +/** Why the response body stopped being read. Closed, and persisted as such. */ +export const REQUEST_CLOSE_REASONS = Object.freeze([ + "terminal", + "client_cancel", + "non_stream", + "body_stall", + "body_overflow", +] as const); + +export type RequestCloseReason = typeof REQUEST_CLOSE_REASONS[number]; + +/** + * Read-back guards for the two facts that reach a durable row as strings. + * + * `terminalStatus` was typed `string` on the persisted entry and copied through the normalizer + * on truthiness alone, unlike the inbound protocol, transport phase and terminal source beside + * it. That was harmless while the value was only rendered; it stops being harmless the moment + * the value becomes part of a grouping key, because the string is assembled from an upstream + * frame and an unvalidated one would put upstream-controlled text into the key. + */ +export function isRequestTerminalStatus(value: unknown): value is RequestTerminalStatus { + return typeof value === "string" + && (REQUEST_TERMINAL_STATUSES as readonly string[]).includes(value); +} + +export function isRequestCloseReason(value: unknown): value is RequestCloseReason { + return typeof value === "string" + && (REQUEST_CLOSE_REASONS as readonly string[]).includes(value); +} + /** * The facts a terminal classification is allowed to read. * diff --git a/src/usage/retention-contract.ts b/src/usage/retention-contract.ts new file mode 100644 index 00000000000..bc8d10feece --- /dev/null +++ b/src/usage/retention-contract.ts @@ -0,0 +1,28 @@ +/** + * The usage-ledger retention limits, stated once for the schema, the server and the dashboard. + * + * Import-free, for the reason `telemetry-contract.ts` gives: the dashboard is a separate + * TypeScript project, and a type-only import still drags the imported file's whole graph into + * the browser build. Anything added here must keep that property. + */ + +/** + * Below this a ceiling cannot hold even one large row plus its successor, so a value under it is + * treated as unset rather than enforced into an empty ledger. + */ +export const MIN_USAGE_LEDGER_MAX_BYTES = 1024 * 1024; + +/** What the dashboard offers when a user turns the limit on. Not a default for the proxy. */ +export const SUGGESTED_USAGE_LEDGER_MAX_BYTES = 1024 * 1024 * 1024; + +/** + * Trim to this fraction of the ceiling rather than to the ceiling itself, so the next append does + * not immediately re-cross it and charge every subsequent append a full rewrite. + */ +export const USAGE_LEDGER_RETENTION_TARGET_RATIO = 0.9; + +export interface UsageLedgerRetentionStatus { + /** Absent when no limit is configured. */ + maxBytes?: number; + currentBytes: number; +} diff --git a/src/usage/telemetry-contract.ts b/src/usage/telemetry-contract.ts index 21e716cb580..ff8a0e08901 100644 --- a/src/usage/telemetry-contract.ts +++ b/src/usage/telemetry-contract.ts @@ -59,6 +59,160 @@ export const ATTEMPT_RECOVERY_WITHHELD_ROSTER = Object.freeze([ export type AttemptRecoveryWithheld = typeof ATTEMPT_RECOVERY_WITHHELD_ROSTER[number]; +/** + * How far a failed exchange got, ordered by how much the DOWNSTREAM CLIENT observed. + * + * The order is by client observation rather than by upstream progress, because the question it + * answers is whether resending can duplicate something the caller already saw. An upstream that + * completed a turn we never relayed has committed nothing downstream; an upstream that emitted + * one token has. + * + * The roster lives here rather than beside the resend tables for the reason stated at the top of + * this file: the dashboard renders a label per member, and reaching the table module for the + * names would drag its import graph into the browser project. `src/lib/request-failure-model.ts` + * re-exports it, so every existing importer keeps its path and there is still exactly one + * declaration. + */ +export const REQUEST_FAILURE_STAGES = Object.freeze([ + /** No response head exists. Whether the origin began the turn is not known from the stage alone. */ + "pre-header", + /** A status line and headers exist, and no protocol body event has been parsed yet. */ + "headers-only", + /** The protocol body began with control events only -- `response.created`, quota frames. */ + "protocol-prelude", + /** At least one output-bearing event reached the caller. */ + "semantic-output", + /** A tool call or other externally visible effect was emitted. */ + "side-effect", + /** A terminal event settled the turn after its answer reached the caller. */ + "terminal", +] as const); + +export type RequestFailureStage = typeof REQUEST_FAILURE_STAGES[number]; + +/** + * Why the request failed, as one closed dictionary for every layer. + * + * Bounded on purpose: these are wire values a maintainer reads and a metric labels by, never a + * credential, an account identifier, an upstream body or prompt content. That bound is what lets + * the value be a Prometheus label and a grouping key without a masking pass -- a closed roster + * has nothing to mask. + */ +export const REQUEST_FAILURE_CAUSES = Object.freeze([ + /** The bytes provably never reached the origin: connect refused, DNS failure, TLS handshake. */ + "transport-unsent", + /** The bytes left and the connection died before a head. The origin may be running the turn. */ + "transport-ambiguous", + /** The origin answered that it would not start the turn now: 503, overloaded, backpressure. */ + "upstream-declined", + /** A 429 rate limit. Capacity is momentarily gone; waiting is the remedy. */ + "rate-limit", + /** Plan or credit quota is gone. Waiting out a retry window does not help; the account must change. */ + "quota-exhausted", + /** Credentials were rejected: 401, 403 on identity. */ + "credential-rejected", + /** The origin evaluated the content and refused it. Identical bytes get the identical refusal. */ + "policy-refusal", + /** + * The origin rejected a request PARAMETER rather than the content: an unsupported reasoning + * effort, an unknown field. Distinct from `policy-refusal` because the remedy is opposite -- + * the same content succeeds once the parameter is adjusted. + */ + "parameter-rejected", + /** Opaque replay state was rejected as unverifiable. Only a request without it can succeed. */ + "ciphertext-refusal", + /** + * The payload exceeded a size the origin accepts. A smaller rebuild of the same turn can + * succeed, which is why this is not the same answer as `payload-rejected`. + */ + "payload-too-large", + /** The payload was rejected on its merits: unsupported media, malformed part. No repair helps. */ + "payload-rejected", + /** + * The origin returned a server-side fault. Whether it had already begun the turn is not + * knowable from the status, so this is the honest classification for the mixed 5xx set the + * transient layer retries: 503 really did decline, 500 may not have. + */ + "upstream-fault", + /** The turn settled carrying no usable output. */ + "empty-output", + /** The caller went away. */ + "client-cancelled", + /** This proxy refused before dispatch: send budget, route policy, replay refusal. */ + "local-refusal", +] as const); + +export type RequestFailureCause = typeof REQUEST_FAILURE_CAUSES[number]; + +/** + * Whether this proxy may send the request again. Derived at READ time from the stage and the + * cause and never persisted, so a stored row cannot carry a verdict that the current table + * would no longer reach. + * + * Every refusal names WHY it refused, because the three reasons need different operator + * responses and used to arrive as one undifferentiated "no retry". + */ +export const RESEND_PERMISSIONS = Object.freeze([ + /** The same request may be sent again. */ + "permitted", + /** Only a modified request may be sent: rotated credential, stripped ciphertext. */ + "permitted-after-repair", + /** Upstream execution state is unknown. No AUTOMATIC resend. */ + "refused-ambiguous", + /** The caller already observed output or an externally visible effect. */ + "refused-committed", + /** Identical bytes would get the identical answer. */ + "refused-futile", +] as const); + +export type ResendPermission = typeof RESEND_PERMISSIONS[number]; + +/** + * Where a terminal or failure was observed on the wire. + * + * Declared here because three modules read it as a closed set -- the durable row's validator, + * the failure attribution and the failure fingerprint -- and a fourth restatement in a test is + * how a member added later leaves an "exhaustive" cross product green without exercising it. + */ +export const REQUEST_TRANSPORT_PHASES = Object.freeze([ + "pre_headers", + "mid_stream", + "terminal_sse", +] as const); + +export type RequestTransportPhase = typeof REQUEST_TRANSPORT_PHASES[number]; + +/** + * What an attempt actually delivered, as five bounded counts (#3983). + * + * #3983 wanted these signals and emitted one debug line per event to get them. That is a second + * durable record: `emitDebugLine` writes the in-process ring AND stderr, and stderr is redirected + * to the service log under both launchd and systemd, so an installed service ends up with a + * per-event history beside the ledger, carrying its own retention, sequencing and identity. It + * also fingerprinted each payload under a process-global random key, which makes every repeated + * prompt fragment, tool name and error message correlatable for the process lifetime. + * + * Counts answer the same questions -- a missing terminal, adapter-to-client loss, empty output, + * partial output size -- and cannot carry content at all. They ride the attempt, so they inherit + * the ledger's normalization, masking and retention rather than acquiring their own. + * + * Counted where the event is DELIVERED, not where it is read. An adapter event the client never + * received is exactly the discrepancy worth seeing, and counting both ends at the reader would + * make the two numbers equal by construction. + */ +export interface AttemptDeliverySummary { + /** Events this attempt's adapter produced. */ + adapterEvents: number; + /** Frames that reached the client transport, after a successful enqueue. */ + relayedEvents: number; + /** UTF-8 bytes of output-bearing delta actually relayed. Never the content itself. */ + semanticBytes: number; + /** Externally visible effects relayed: a tool call or a search call starting. */ + sideEffectEvents: number; + /** Terminal frames relayed. Zero on a delivered stream is the missing-terminal signal. */ + terminalEvents: number; +} + /** * What one logical request spent upstream, decomposed by how much of it is explained. * diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index de51d06c3c4..726af4a7023 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -528,6 +528,21 @@ monitoring or protection against another process changing the path again after t An opt-in shadow-call rewrite persists the bounded, redacted original helper model as `shadowCallRewrittenFrom`, so helper traffic remains identifiable after restart without storing request content or inferring a helper subtype from timing. +A failed request persists closed `failureStage` and `failureCause` members on the attempt that ended +it and on the logical row, derived once at `addFinalRequestLog` from facts that are themselves +closed; `errorCode` and `upstreamError` carry upstream text and are deliberately not read there. The +resend verdict they imply is never stored — `/api/logs` computes `resendPermission` at read time, so +a row written by an older build cannot assert a permission the current tables refuse. An attempt also +carries `deliverySummary`: adapter events, relayed frames, semantic bytes, side effects and terminal +frames, counted where each event is delivered rather than where it is read, so the gap between the +first two is the loss signal. Provider debug formats one ring line per finalized attempt from those +counts and writes no second record. `GET /api/usage?failures=1` groups failed rows by a versioned +fingerprint over closed vocabularies only, rebuilt through the same cooperative scanner and +inheriting its bounds, so deleting a ledger row removes it from the grouping. +`usageLedgerMaxBytes` is unset by default; when set, an append that crosses it publishes the newest +whole rows byte for byte through the shared atomic writer, refuses the rename unless the source is +the exact revision that was copied, and then discards the Logs ring, the retained aggregates and the +request-history index so no surface serves rows the ledger no longer has. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. The management route scans the ledger from its beginning in fixed 1 MiB chunks on a @@ -625,11 +640,15 @@ log scan, or persistence. Restart creates a fresh owner, resets every counter/hi The label vocabularies are closed: protocol is `responses`, `chat`, `messages`, or `unknown`; result is `completed`, `failed`, `incomplete`, or `aborted`; recovery is one of the coarse classes listed in -`REQUEST_METRICS_RECOVERY_CLASSES`, which is the roster the exporter itself iterates. The count is +`REQUEST_METRICS_RECOVERY_CLASSES`, and cause is one of the shared failure causes in +`REQUEST_METRICS_FAILURE_CAUSES`, which aliases the dictionary rather than copying it. Each is the +roster the exporter itself iterates. The count is deliberately not restated here: it was written as eight, a bounded label value was added, and the documentation then contradicted the output it describes. A logical request increments once, physical sends sum the finalized attempt counts, and each distinct -recovery kind already retained on an attempt contributes once to its coarse class. HTTP 200 never +recovery kind already retained on an attempt contributes once to its coarse class. +`opencodex_request_failures_total` counts the cause the recorder derived and never re-derives one, +and it labels a counter only: no histogram carries a cause. HTTP 200 never overrides a failed terminal event. Duration observes every valid finalized duration; TTFT observes only finite nonnegative first-output values, while `opencodex_ttft_missing_total` is the complementary denominator. No request, credential, account, provider, model, conversation, raw error, prompt, tool, diff --git a/structure/overview.md b/structure/overview.md index 67fb90bfaa1..13f226e3088 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -138,13 +138,21 @@ still cover the rule, which is a judgement only review makes. there, reported as an unidentified holder otherwise. A configured `port: 0` still asks the OS for a port, and an explicit `--port` still waits for its pin instead of hopping. Enforced by `tests/cli/cli-dispatch.test.ts`. -- **INV-RESEND-01** — One vocabulary in `src/lib/request-failure-model.ts` states how far a failed - request got, why it failed, and whether it may be sent again. Once the caller has observed output - or an externally visible effect, no cause automatically permits a resend, and a cause whose - upstream execution state is unknown is not made replayable by having budget left. A refusal names - which of the three refusals it is. The decision is derived from per-stage and per-cause facts - rather than written out as a stage-by-cause matrix, so a new member cannot leave a stale cell. +- **INV-RESEND-01** — One vocabulary states how far a failed request got, why it failed, and whether + it may be sent again. The rosters are declared in the import-free `src/usage/telemetry-contract.ts` + so the dashboard can name their members, and `src/lib/request-failure-model.ts` re-exports them and + owns the decision. Once the caller has observed output or an externally visible effect, no cause + automatically permits a resend, and a cause whose upstream execution state is unknown is not made + replayable by having budget left. A refusal names which of the three refusals it is. The decision is + derived from per-stage and per-cause facts rather than written out as a stage-by-cause matrix, so a + new member cannot leave a stale cell. Enforced by `tests/lib/failure-stage-model.test.ts`. +- **INV-ATTRIBUTION-01** — `src/lib/request-failure-attribution.ts` derives the persisted failure + stage and cause from closed recorder facts only, never from `errorCode` or `upstreamError`, which + are assembled partly from upstream text. An unknown upstream execution state is attributed to a + cause that refuses an automatic resend rather than to one that permits it, and the resend verdict + the pair implies is computed at read time and never persisted. + Enforced by `tests/lib/failure-attribution.test.ts`. - **INV-RESEND-02** — One logical request holds one operator-granted replacement for an ambiguous failure, however many stages ask for it. `src/lib/request-resend-gate.ts` is the only place that override is applied, it claims the grant at the moment it authorises rather than earlier, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7976fcd8a71..d0ff792be6d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1020,6 +1020,7 @@ "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", "failure-stage-model.test.ts": "lib", + "failure-attribution.test.ts": "lib", "relay-eager.test.ts": "server", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", @@ -1058,6 +1059,10 @@ "request-log-estimate-cap.test.ts": "usage", "request-log.test.ts": "usage", "request-outcome-agreement.test.ts": "usage", + "usage-failure-fingerprint.test.ts": "usage", + "usage-failure-projection.test.ts": "usage", + "usage-attempt-delivery.test.ts": "usage", + "usage-ledger-retention.test.ts": "usage", "request-pacing.test.ts": "usage", "reserve-auth-context.test.ts": "codex-integration", "reserve-availability.test.ts": "codex-integration", diff --git a/tests/lib/failure-attribution.test.ts b/tests/lib/failure-attribution.test.ts new file mode 100644 index 00000000000..8b8b438d2a7 --- /dev/null +++ b/tests/lib/failure-attribution.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "bun:test"; +import { + deriveRequestFailureAttribution, + deriveRequestFailureCause, + deriveRequestFailureStage, + type RequestFailureFacts, +} from "../../src/lib/request-failure-attribution"; +import { + REQUEST_FAILURE_CAUSES, + REQUEST_FAILURE_STAGES, + permitsResend, + resendPermission, + stageCommitment, +} from "../../src/lib/request-failure-model"; +import { ATTEMPT_RECOVERY_KIND_ROSTER } from "../../src/usage/telemetry-contract"; +import { + REQUEST_CLOSE_REASONS, + REQUEST_OUTCOME_CLASSES, + REQUEST_TERMINAL_STATUSES, + classifyRequestOutcome, +} from "../../src/usage/request-outcome"; +import { REQUEST_TRANSPORT_PHASES } from "../../src/usage/telemetry-contract"; + +/** + * The fact space this derivation is total over. + * + * Holds INV-ATTRIBUTION-01 from structure/overview.md. + * + * Every axis is read from the module that declares it rather than restated, so a member added to + * a roster widens this cross product instead of leaving a case nobody wrote. The status list is + * the one axis that cannot be derived -- HTTP statuses are not a roster this repository owns -- + * so it enumerates one representative per branch the derivation distinguishes, plus the two + * boundary values (`0`, no head at all, and `499`) that decide a branch on their own. + */ +const STATUSES = [0, 101, 200, 400, 401, 403, 413, 429, 451, 499, 500, 502, 503] as const; +// Read from the modules that declare them, so a member added later widens this space instead of +// leaving a case nobody wrote. Restating them is what let the recovery roster drift to nine of +// thirteen while every test stayed green. +const TERMINAL_STATUSES = [undefined, ...REQUEST_TERMINAL_STATUSES] as const; +const CLOSE_REASONS = [undefined, ...REQUEST_CLOSE_REASONS] as const; +const TRANSPORT_PHASES = [undefined, ...REQUEST_TRANSPORT_PHASES] as const; + +function* factSpace(): Generator { + for (const status of STATUSES) { + for (const terminalStatus of TERMINAL_STATUSES) { + for (const closeReason of CLOSE_REASONS) { + for (const transportPhase of TRANSPORT_PHASES) { + for (const outputObserved of [false, true]) { + for (const sideEffectObserved of [false, true]) { + yield { + status, + ...(terminalStatus ? { terminalStatus } : {}), + ...(closeReason ? { closeReason } : {}), + ...(transportPhase ? { transportPhase } : {}), + outputObserved, + sideEffectObserved, + }; + } + } + } + } + } + } +} + +describe("request failure attribution", () => { + test("every derived stage and cause is a member of the landed rosters", () => { + let seen = 0; + for (const facts of factSpace()) { + seen += 1; + expect(REQUEST_FAILURE_STAGES).toContain(deriveRequestFailureStage(facts)); + const cause = deriveRequestFailureCause(facts); + if (cause !== undefined) expect(REQUEST_FAILURE_CAUSES).toContain(cause); + } + // The generator is the oracle: an axis added above must actually widen the space. + expect(seen).toBe( + STATUSES.length * TERMINAL_STATUSES.length * CLOSE_REASONS.length * TRANSPORT_PHASES.length * 4, + ); + }); + + test("attribution is recorded for exactly the outcomes that are not completed", () => { + const outcomesWithAttribution = new Set(); + for (const facts of factSpace()) { + const outcome = classifyRequestOutcome(facts); + const attribution = deriveRequestFailureAttribution(facts); + if (outcome === "completed") { + expect(attribution).toBeUndefined(); + continue; + } + expect(attribution).toBeDefined(); + outcomesWithAttribution.add(outcome); + } + expect([...outcomesWithAttribution].toSorted()) + .toEqual(REQUEST_OUTCOME_CLASSES.filter(outcome => outcome !== "completed").toSorted()); + }); + + test("a cause is recorded for a failure and withheld from an incomplete turn", () => { + for (const facts of factSpace()) { + const outcome = classifyRequestOutcome(facts); + const cause = deriveRequestFailureCause(facts); + if (outcome === "completed" || outcome === "incomplete") expect(cause).toBeUndefined(); + else expect(cause).toBeDefined(); + } + }); + + test("an aborted request is attributed to the caller, whichever way it was signalled", () => { + expect(deriveRequestFailureCause({ status: 499 })).toBe("client-cancelled"); + expect(deriveRequestFailureCause({ status: 502, closeReason: "client_cancel" })).toBe("client-cancelled"); + }); + + test("a stage never claims more than the caller observed", () => { + for (const facts of factSpace()) { + const stage = deriveRequestFailureStage(facts); + if (facts.outputObserved !== true) { + // Nothing reached the caller, so no stage may report an irreversible observation. + expect(stageCommitment(stage)).not.toBe("output-observed"); + expect(stageCommitment(stage)).not.toBe("answer-delivered"); + } + } + }); + + test("an observed side effect refuses a resend for every cause", () => { + const facts: RequestFailureFacts = { status: 502, sideEffectObserved: true }; + const stage = deriveRequestFailureStage(facts); + for (const cause of REQUEST_FAILURE_CAUSES) { + expect(permitsResend(resendPermission(stage, cause))).toBe(false); + } + }); + + test("a 400 is refined by the recovery kind that identifies which rejection it was", () => { + const base = { status: 400 } as const; + expect(deriveRequestFailureCause(base)).toBe("payload-rejected"); + expect(deriveRequestFailureCause({ ...base, recoveryKinds: ["opaque-blob-rejection"] })) + .toBe("ciphertext-refusal"); + expect(deriveRequestFailureCause({ ...base, recoveryKinds: ["reasoning-effort-downgrade"] })) + .toBe("parameter-rejected"); + }); + + test("a recovery kind does not become the cause when the request ended on another status", () => { + // The attempt recovered from a rejected reasoning parameter and then died on a 500. The + // request failed for the 500, and reporting the earlier rejection would send an operator + // after a problem that was already worked around. + for (const kind of ATTEMPT_RECOVERY_KIND_ROSTER) { + expect(deriveRequestFailureCause({ status: 500, recoveryKinds: [kind] })).toBe("upstream-fault"); + } + }); + + test("a turn that settled with no output is empty output rather than an upstream fault", () => { + expect(deriveRequestFailureCause({ status: 200, terminalStatus: "failed", outputObserved: false })) + .toBe("empty-output"); + expect(deriveRequestFailureCause({ status: 200, terminalStatus: "failed", outputObserved: true })) + .toBe("upstream-fault"); + }); + + test("a stream that died mid-flight is ambiguous, not an upstream fault", () => { + // The production shape: the relay reports a SYNTHETIC 502 after a mid-stream read failure + // and marks the attempt aborted. Reading the 502 in status order would claim the origin + // answered when it did not. + expect(deriveRequestFailureCause({ + status: 502, transportPhase: "mid_stream", terminalSource: "synthetic", + })).toBe("transport-ambiguous"); + expect(deriveRequestFailureCause({ status: 502, streamAborted: true })).toBe("transport-ambiguous"); + // An upstream 502 that is genuinely upstream stays an upstream fault. + expect(deriveRequestFailureCause({ status: 502, terminalSource: "upstream" })).toBe("upstream-fault"); + }); + + test("an unknown execution state never reads as a proven unsent send", () => { + // `transport-unsent` permits an automatic resend, so it is reachable only from a site that + // classified a pre-connect failure and can prove it. Everything else answers ambiguously, + // which is the safe direction. + expect(deriveRequestFailureCause({ status: 0 })).toBe("transport-ambiguous"); + expect(deriveRequestFailureCause({ status: 0, causeHint: "transport-unsent" })).toBe("transport-unsent"); + }); + + test("payment required is a quota problem, not a bad payload", () => { + expect(deriveRequestFailureCause({ status: 402 })).toBe("quota-exhausted"); + }); + + test("a local refusal is attributed to this proxy rather than to upstream", () => { + expect(deriveRequestFailureCause({ status: 502, locallyAnswered: true })).toBe("local-refusal"); + }); + + test("a cause the finalizer proved outranks the status table", () => { + // The key-account rotation seals the previous attempt because a named recovery rejected it. + // That argument is evidence; reconstructing the cause from 502 would lose it. + expect(deriveRequestFailureCause({ status: 502, causeHint: "credential-rejected" })) + .toBe("credential-rejected"); + // A client cancel is a fact about the caller and still outranks the hint. + expect(deriveRequestFailureCause({ status: 499, causeHint: "credential-rejected" })) + .toBe("client-cancelled"); + }); + + test("only the last recovery refines a 400, and only on its own status", () => { + // The attempt recovered from a rejected ciphertext and then hit a rejected parameter. + expect(deriveRequestFailureCause({ + status: 400, + recoveryKinds: ["opaque-blob-rejection", "reasoning-effort-downgrade"], + })).toBe("parameter-rejected"); + // A recovery for a different status never refines this one. + expect(deriveRequestFailureCause({ status: 400, recoveryKinds: ["oauth-401"] })) + .toBe("payload-rejected"); + }); + + test("a relayed side effect raises the stage above observed output", () => { + expect(deriveRequestFailureStage({ status: 502, outputObserved: true })).toBe("semantic-output"); + expect(deriveRequestFailureStage({ status: 502, outputObserved: true, sideEffectObserved: true })) + .toBe("side-effect"); + }); +}); diff --git a/tests/server/management-metrics-export.test.ts b/tests/server/management-metrics-export.test.ts index 9759a2d91b0..560874e661b 100644 --- a/tests/server/management-metrics-export.test.ts +++ b/tests/server/management-metrics-export.test.ts @@ -21,6 +21,7 @@ import { REQUEST_DURATION_BUCKETS_SECONDS, REQUEST_METRICS_PROTOCOLS, REQUEST_METRICS_RECOVERY_CLASSES, + REQUEST_METRICS_FAILURE_CAUSES, REQUEST_METRICS_RESULTS, REQUEST_TTFT_BUCKETS_SECONDS, } from "../../src/server/request-metrics"; @@ -462,6 +463,7 @@ describe("request metrics aggregation", () => { cells + REQUEST_METRICS_PROTOCOLS.length + REQUEST_METRICS_PROTOCOLS.length * REQUEST_METRICS_RECOVERY_CLASSES.length + + REQUEST_METRICS_PROTOCOLS.length * REQUEST_METRICS_FAILURE_CAUSES.length + cells * perHistogram(REQUEST_DURATION_BUCKETS_SECONDS) + cells * perHistogram(REQUEST_TTFT_BUCKETS_SECONDS) + cells @@ -480,10 +482,15 @@ describe("request metrics aggregation", () => { .toBeLessThan(output.indexOf("opencodex_request_duration_seconds_bucket")); const helpLines = output.split("\n").filter(line => line.startsWith("# HELP ")); const typeLines = output.split("\n").filter(line => line.startsWith("# TYPE ")); - expect(helpLines).toHaveLength(7); - expect(typeLines).toHaveLength(7); - expect(new Set(helpLines.map(line => line.split(" ")[2])).size).toBe(7); - expect(new Set(typeLines.map(line => line.split(" ")[2])).size).toBe(7); + // Every metric name the exporter emits, read from the exposition rather than counted by + // hand: the literal was correct until a metric was added, which is the same staleness the + // sample arithmetic above avoids. + const metricNames = new Set(helpLines.map(line => line.split(" ")[2])); + expect(helpLines).toHaveLength(metricNames.size); + expect(typeLines).toHaveLength(metricNames.size); + expect(new Set(typeLines.map(line => line.split(" ")[2]))).toEqual(metricNames); + // Each name appears exactly once in each group, which is what deterministic grouping means. + expect(helpLines.length).toBeGreaterThan(REQUEST_METRICS_PROTOCOLS.length); expect(sampleValue(output, 'opencodex_request_duration_seconds_bucket{protocol="responses",result="completed",le="+Inf"}')) .toBe(sampleValue(output, 'opencodex_request_duration_seconds_count{protocol="responses",result="completed"}')); expect(metrics.snapshot()).toBe(output); diff --git a/tests/usage/request-outcome-agreement.test.ts b/tests/usage/request-outcome-agreement.test.ts index b5b30744e9e..7b1b49dbf65 100644 --- a/tests/usage/request-outcome-agreement.test.ts +++ b/tests/usage/request-outcome-agreement.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from "node:fs"; import { repoPath } from "../helpers/repo-root"; import { REQUEST_OUTCOME_CLASSES, + REQUEST_CLOSE_REASONS, + REQUEST_TERMINAL_STATUSES, classifyRequestOutcome, requestPhysicalSends, requestSettledSends, @@ -11,6 +13,8 @@ import { } from "../../src/usage/request-outcome"; import { REQUEST_METRICS_RESULTS, + REQUEST_METRICS_FAILURE_CAUSES, + REQUEST_METRICS_PROTOCOLS, createRequestMetricsOwner, } from "../../src/server/request-metrics"; import { ATTEMPT_RECOVERY_KIND_ROSTER } from "../../src/usage/telemetry-contract"; @@ -32,11 +36,13 @@ function sampleValue(snapshot: string, series: string): number { return line === undefined ? Number.NaN : Number(line.slice(series.length + 1)); } -/** Every combination a terminal can arrive in, built from the declared vocabularies. */ -const TERMINAL_STATUSES = [undefined, "completed", "failed", "incomplete"] as const; -const CLOSE_REASONS = [ - undefined, "terminal", "client_cancel", "non_stream", "body_stall", "body_overflow", -] as const; +/** + * Every combination a terminal can arrive in, read from the modules that declare them rather + * than written out. A restated list is how a member added later leaves this cross product green + * without ever being exercised. + */ +const TERMINAL_STATUSES = [undefined, ...REQUEST_TERMINAL_STATUSES] as const; +const CLOSE_REASONS = [undefined, ...REQUEST_CLOSE_REASONS] as const; const STATUSES = [101, 200, 204, 399, 400, 429, 499, 500, 502] as const; describe("terminal classification is stated once", () => { @@ -185,13 +191,15 @@ describe("the dashboard recovery roster cannot drift from the durable one", () = test("the page derives the union rather than restating it", () => { const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); - expect(page).toContain("import type { AttemptRecoveryKind"); + // Matched without depending on how the import is wrapped: the property is that the name + // arrives from the contract leaf, not that it sits first on a single line. + expect(/import type \{[^}]*\bAttemptRecoveryKind\b[^}]*\}\s*from\s*"[^"]*usage\/telemetry-contract"/s.test(page)).toBe(true); expect(page).not.toContain('type AttemptRecoveryKind ='); }); test("every label key the page names exists in all ten catalogs", () => { const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); - const keys = [...new Set([...page.matchAll(/"(logs\.detail\.(?:attempt\.recovery|outcome|sends)\.[a-zA-Z0-9]+)"/g)] + const keys = [...new Set([...page.matchAll(/"(logs\.detail\.(?:attempt\.recovery|outcome|sends|cause|stage|resend)\.[a-zA-Z0-9]+)"/g)] .map(match => match[1]!))]; expect(keys.length).toBeGreaterThan(ATTEMPT_RECOVERY_KIND_ROSTER.length); const gaps: string[] = []; @@ -214,6 +222,70 @@ describe("the exporter stays bounded", () => { } const snapshot = metrics.snapshot(); const labels = [...new Set([...snapshot.matchAll(/([a-z_]+)="/g)].map(match => match[1]!))]; - expect(labels.sort()).toEqual(["le", "protocol", "recovery", "result"]); + expect(labels.sort()).toEqual(["cause", "le", "protocol", "recovery", "result"]); + }); + + /** + * The cause label is a counter label and never a histogram one. Fifteen causes across four + * protocols is a fixed sixty series; the same label on a histogram would multiply that by the + * bucket count to answer a question nobody asked. + */ + test("the failure cause labels a counter and no histogram", () => { + const snapshot = createRequestMetricsOwner(1).snapshot(); + const causeLines = snapshot.split("\n").filter(line => line.includes('cause="')); + expect(causeLines.length).toBe( + REQUEST_METRICS_PROTOCOLS.length * REQUEST_METRICS_FAILURE_CAUSES.length, + ); + expect(causeLines.every(line => line.startsWith("opencodex_request_failures_total{"))).toBe(true); + expect(causeLines.some(line => line.includes("_bucket") || line.includes("le="))).toBe(false); + }); +}); + +describe("the failure cause is derived once and reported everywhere", () => { + /** + * The recorder derives the cause; the exporter counts the value it was handed. Two derivations + * of one answer is the disagreement this batch exists to remove, so this asserts the exporter + * has no opinion of its own -- a fact carrying a cause the status alone would classify + * differently is still counted under the cause it was given. + */ + test("the exporter counts the recorder's cause rather than re-deriving one", () => { + for (const cause of REQUEST_METRICS_FAILURE_CAUSES) { + const metrics = createRequestMetricsOwner(1); + metrics.recordFinalRequest({ + protocol: "responses", status: 200, durationMs: 1, terminalStatus: "failed", failureCause: cause, + }); + const snapshot = metrics.snapshot(); + expect(sampleValue(snapshot, `opencodex_request_failures_total{protocol="responses",cause="${cause}"}`)).toBe(1); + } + }); + + test("a request that delivered its answer contributes to no cause series", () => { + const metrics = createRequestMetricsOwner(1); + metrics.recordFinalRequest({ protocol: "responses", status: 200, durationMs: 1, terminalStatus: "completed" }); + const snapshot = metrics.snapshot(); + for (const cause of REQUEST_METRICS_FAILURE_CAUSES) { + expect(sampleValue(snapshot, `opencodex_request_failures_total{protocol="responses",cause="${cause}"}`)).toBe(0); + } + }); + + test("every cause the recorder can derive has a dashboard label", () => { + const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); + const block = page.slice(page.indexOf("const FAILURE_CAUSE_KEYS"), page.indexOf("} as const satisfies Record !block.includes(`"${cause}":`)); + expect(missing).toEqual([]); + }); + + /** + * The verdict is computed at read time and must not appear in a durable shape. A row written + * by an older build would otherwise assert a permission the current tables refuse, and there + * would be no way to correct it. + */ + test("the resend verdict is never persisted", () => { + const ledger = readFileSync(repoPath("src", "usage", "log.ts"), "utf8"); + expect(ledger).toContain("failureStage"); + expect(ledger).toContain("failureCause"); + expect(ledger).not.toContain("resendPermission"); + const dto = readFileSync(repoPath("src", "server", "management", "shared.ts"), "utf8"); + expect(dto).toContain("resendPermission"); }); }); diff --git a/tests/usage/usage-attempt-delivery.test.ts b/tests/usage/usage-attempt-delivery.test.ts new file mode 100644 index 00000000000..f5681baf7a2 --- /dev/null +++ b/tests/usage/usage-attempt-delivery.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { + attemptDeliveryRecorder, + bindAttemptDeliveryRecorder, + classifyRelayedResponseEvent, + createAttemptDeliverySummary, + normalizeAttemptDeliverySummary, + type AttemptDeliveryTarget, +} from "../../src/usage/attempt-delivery"; +import { normalizeUsageEntryForTest, type PersistedUsageEntry } from "../../src/usage/log"; + +const COUNTERS = Object.keys(createAttemptDeliverySummary()); + +describe("attempt delivery summary", () => { + test("the recorder credits the attempt that is live when the event arrives", () => { + let active: AttemptDeliveryTarget | undefined = {}; + const first = active; + const scope = {}; + const recorder = bindAttemptDeliveryRecorder(scope, () => active); + recorder.noteAdapterEvent(); + recorder.noteRelayedEvent({ semanticBytes: 4, terminal: true }); + + // A key-account change seals the attempt and starts a fresh one mid-request. A recorder + // holding a reference would keep crediting the sealed row. + const second: AttemptDeliveryTarget = {}; + active = second; + recorder.noteAdapterEvent(); + + expect(first.deliverySummary).toEqual({ + adapterEvents: 1, relayedEvents: 1, semanticBytes: 4, sideEffectEvents: 0, terminalEvents: 1, + }); + expect(second.deliverySummary).toEqual({ + adapterEvents: 1, relayedEvents: 0, semanticBytes: 0, sideEffectEvents: 0, terminalEvents: 0, + }); + }); + + test("a scope with no attempt records nothing and does not throw", () => { + const scope = {}; + const recorder = bindAttemptDeliveryRecorder(scope, () => undefined); + expect(() => { recorder.noteAdapterEvent(); recorder.noteRelayedEvent(); }).not.toThrow(); + expect(attemptDeliveryRecorder(scope)).toBe(recorder); + expect(attemptDeliveryRecorder(undefined)).toBeUndefined(); + expect(attemptDeliveryRecorder({})).toBeUndefined(); + }); + + test("semantic bytes count delivered UTF-8, never the text itself", () => { + const observation = classifyRelayedResponseEvent("response.output_text.delta", { delta: "한글" }); + expect(observation.semanticBytes).toBe(6); + expect(JSON.stringify(observation)).not.toContain("한글"); + }); + + test("one tool call is one side effect, whatever carried its arguments", () => { + const target: AttemptDeliveryTarget = {}; + const recorder = bindAttemptDeliveryRecorder({}, () => target); + recorder.noteRelayedEvent(classifyRelayedResponseEvent("response.output_item.added", { + item: { type: "function_call", name: "lookup" }, + })); + recorder.noteRelayedEvent(classifyRelayedResponseEvent("response.function_call_arguments.delta", { delta: "{}" })); + recorder.noteRelayedEvent(classifyRelayedResponseEvent("response.output_item.done", { + item: { type: "function_call", name: "lookup" }, + })); + expect(target.deliverySummary!.sideEffectEvents).toBe(1); + expect(target.deliverySummary!.relayedEvents).toBe(3); + }); + + test("framing and control frames are relayed events but not semantic or terminal ones", () => { + for (const name of ["response.created", "response.heartbeat", "response.output_item.done"]) { + const observation = classifyRelayedResponseEvent(name, {}); + expect(observation.semanticBytes).toBeUndefined(); + expect(observation.terminal).toBeUndefined(); + expect(observation.sideEffect).toBeUndefined(); + } + for (const name of ["response.completed", "response.incomplete", "response.failed"]) { + expect(classifyRelayedResponseEvent(name, {}).terminal).toBe(true); + } + }); + + test("a summary survives the ledger round trip and an invalid one is dropped whole", () => { + const row = (deliverySummary: unknown): PersistedUsageEntry => ({ + requestId: "req", timestamp: 1, provider: "openai", model: "m", status: 200, + durationMs: 1, usageStatus: "unreported", + attempts: [{ + ordinal: 1, provider: "openai", model: "m", adapter: "openai", status: 200, + durationMs: 1, sendCount: 1, recoveryKinds: [], usageStatus: "unreported", + deliverySummary, + } as never], + }); + const good = { adapterEvents: 5, relayedEvents: 4, semanticBytes: 12, sideEffectEvents: 1, terminalEvents: 1 }; + expect(normalizeUsageEntryForTest(row(good)).attempts![0]!.deliverySummary).toEqual(good); + + // Half a summary is how a loss signal becomes a false one, so a bad count drops all five. + for (const broken of [ + { ...good, relayedEvents: -1 }, + { ...good, semanticBytes: 1.5 }, + { ...good, terminalEvents: "1" }, + { adapterEvents: 1 }, + null, + [], + ]) { + expect(normalizeUsageEntryForTest(row(broken)).attempts![0]!.deliverySummary).toBeUndefined(); + } + // An attempt written before the field existed still reads back. + const legacy = normalizeUsageEntryForTest(row(undefined)); + expect(legacy.attempts![0]!.deliverySummary).toBeUndefined(); + expect(legacy.attempts![0]!.ordinal).toBe(1); + }); + + test("the normalizer requires every declared counter, so a new one cannot be forgotten", () => { + const complete = createAttemptDeliverySummary(); + expect(normalizeAttemptDeliverySummary(complete)).toEqual(complete); + for (const key of COUNTERS) { + const missing: Record = { ...complete }; + delete missing[key]; + expect(normalizeAttemptDeliverySummary(missing)).toBeUndefined(); + } + }); + + test("the summary carries counts only, so no content can ride it", () => { + const target: AttemptDeliveryTarget = {}; + const recorder = bindAttemptDeliveryRecorder({}, () => target); + recorder.noteRelayedEvent(classifyRelayedResponseEvent("response.output_text.delta", { + delta: "the user's private prompt", + })); + const serialized = JSON.stringify(target.deliverySummary); + expect(serialized).not.toContain("private"); + expect(Object.values(target.deliverySummary!).every(value => typeof value === "number")).toBe(true); + }); + + test("a buffered response does not read as total relay loss", () => { + const target: AttemptDeliveryTarget = {}; + const recorder = bindAttemptDeliveryRecorder({}, () => target); + for (let index = 0; index < 4; index += 1) recorder.noteAdapterEvent(); + // Nothing calls the per-frame recorder on a non-streaming turn: the whole answer arrives as + // one body. Left at zero, every buffered request would raise the adapter-to-client loss + // signal these counters exist for. + recorder.noteBufferedDelivery({ + output: [ + { type: "message", content: [{ type: "output_text", text: "hello" }] }, + { type: "function_call", name: "lookup", arguments: "{}" }, + ], + }); + expect(target.deliverySummary).toEqual({ + adapterEvents: 4, + relayedEvents: 4, + semanticBytes: 7, + sideEffectEvents: 1, + terminalEvents: 1, + }); + }); + + test("a buffered body contributes counts and never its text", () => { + const target: AttemptDeliveryTarget = {}; + const recorder = bindAttemptDeliveryRecorder({}, () => target); + recorder.noteAdapterEvent(); + recorder.noteBufferedDelivery({ + output: [{ type: "message", content: [{ type: "output_text", text: "a private answer" }] }], + }); + expect(JSON.stringify(target.deliverySummary)).not.toContain("private"); + expect(target.deliverySummary!.semanticBytes).toBe(16); + }); +}); diff --git a/tests/usage/usage-failure-fingerprint.test.ts b/tests/usage/usage-failure-fingerprint.test.ts new file mode 100644 index 00000000000..ce081ce7034 --- /dev/null +++ b/tests/usage/usage-failure-fingerprint.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { + FAILURE_FINGERPRINT_VERSION, + FAILURE_STATUS_CLASSES, + canonicalFailureFingerprintTuple, + computeFailureFingerprint, + failureStatusClass, + type FailureFingerprintFacts, +} from "../../src/usage/failure-fingerprint"; +import { REQUEST_FAILURE_CAUSES } from "../../src/lib/request-failure-model"; +import { REQUEST_CLOSE_REASONS, REQUEST_TERMINAL_STATUSES } from "../../src/usage/request-outcome"; + +/** + * A fingerprint that can carry content is not privacy-safe, and one that collides is not a + * grouping key. Both properties are asserted structurally rather than by example, so a slot + * added later has to be given a position instead of quietly joining an existing one. + */ +const BASE: FailureFingerprintFacts = { + cause: "upstream-fault", + statusClass: "5xx", + providerClass: null, + inboundProtocol: null, + terminalStatus: null, + closeReason: null, + transportPhase: null, + terminalSource: null, +}; + +/** Every slot, with a value that differs from BASE, read from the roster that declares it. */ +const VARIATIONS: ReadonlyArray<[keyof FailureFingerprintFacts, unknown]> = [ + ["cause", REQUEST_FAILURE_CAUSES.find(cause => cause !== BASE.cause)!], + ["statusClass", FAILURE_STATUS_CLASSES.find(value => value !== BASE.statusClass)!], + ["providerClass", "openai"], + ["inboundProtocol", "responses"], + ["terminalStatus", REQUEST_TERMINAL_STATUSES[0]!], + ["closeReason", REQUEST_CLOSE_REASONS[0]!], + ["transportPhase", "mid_stream"], + ["terminalSource", "upstream"], +]; + +describe("failure fingerprint", () => { + test("it is deterministic and carries its version in the value", () => { + const first = computeFailureFingerprint(BASE); + expect(computeFailureFingerprint({ ...BASE })).toBe(first); + // The version is imported, never written out: a bump must not be contradicted here. + expect(first.startsWith(`v${FAILURE_FINGERPRINT_VERSION}:`)).toBe(true); + }); + + test("every tuple position changes the fingerprint", () => { + const base = computeFailureFingerprint(BASE); + const unchanged: string[] = []; + for (const [key, value] of VARIATIONS) { + const varied = computeFailureFingerprint({ ...BASE, [key]: value } as FailureFingerprintFacts); + if (varied === base) unchanged.push(String(key)); + } + expect(unchanged).toEqual([]); + }); + + test("the tuple has one fixed position per declared fact plus the version", () => { + const tuple = canonicalFailureFingerprintTuple(BASE); + expect(tuple.length).toBe(Object.keys(BASE).length + 1); + expect(tuple[0]).toBe(FAILURE_FINGERPRINT_VERSION); + // Absent facts are explicit nulls. Dropping them would let [a, null, b] and [a, b] collide. + expect(tuple.slice(3).every(slot => slot === null)).toBe(true); + }); + + test("a missing fact is structurally distinct from a present one that looks like it", () => { + const absent = computeFailureFingerprint(BASE); + const present = computeFailureFingerprint({ ...BASE, providerClass: "null" }); + expect(present).not.toBe(absent); + }); + + test("a field outside the declared facts cannot reach the identity", () => { + const withExtras = { + ...BASE, + provider: "cursor-alice@example.com", + model: "secret-model", + upstreamError: "prompt fragment", + requestId: "req_1", + timestamp: 1, + } as FailureFingerprintFacts; + expect(computeFailureFingerprint(withExtras)).toBe(computeFailureFingerprint(BASE)); + expect(JSON.stringify(canonicalFailureFingerprintTuple(withExtras))).not.toContain("example.com"); + }); + + test("the status class covers every hundred and refuses anything else", () => { + expect(failureStatusClass(101)).toBe("1xx"); + expect(failureStatusClass(204)).toBe("2xx"); + expect(failureStatusClass(302)).toBe("3xx"); + expect(failureStatusClass(429)).toBe("4xx"); + expect(failureStatusClass(503)).toBe("5xx"); + for (const value of [undefined, null, 0, 99, 600, 1.5, Number.NaN, "500"]) { + expect(failureStatusClass(value)).toBe("unknown"); + } + }); + + test("every cause the recorder can store produces a distinct fingerprint", () => { + const seen = new Map(); + for (const cause of REQUEST_FAILURE_CAUSES) { + const fingerprint = computeFailureFingerprint({ ...BASE, cause }); + expect(seen.has(fingerprint)).toBe(false); + seen.set(fingerprint, cause); + } + expect(seen.size).toBe(REQUEST_FAILURE_CAUSES.length); + }); +}); diff --git a/tests/usage/usage-failure-projection.test.ts b/tests/usage/usage-failure-projection.test.ts new file mode 100644 index 00000000000..479ab3962c0 --- /dev/null +++ b/tests/usage/usage-failure-projection.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { + createFailureProjectionAccumulator, + failureProviderClass, +} from "../../src/usage/failure-projection"; +import { FAILURE_FINGERPRINT_VERSION } from "../../src/usage/failure-fingerprint"; +import { REQUEST_FAILURE_CAUSES } from "../../src/lib/request-failure-model"; +import type { PersistedUsageEntry } from "../../src/usage/log"; + +function row(overrides: Partial): PersistedUsageEntry { + return { + requestId: "req", + timestamp: 1_000, + provider: "openai", + model: "gpt-x", + status: 502, + durationMs: 1, + usageStatus: "unreported", + failureCause: "upstream-fault", + ...overrides, + }; +} + +describe("failure projection", () => { + test("it groups failures that share every closed fact and separates the rest", () => { + const accumulator = createFailureProjectionAccumulator(); + accumulator.add(row({ timestamp: 30 })); + accumulator.add(row({ timestamp: 10, requestId: "req2", model: "another-model" })); + accumulator.add(row({ timestamp: 20, requestId: "req3", failureCause: "rate-limit", status: 429 })); + const snapshot = accumulator.snapshot(); + + expect(snapshot.groups.length).toBe(2); + const fault = snapshot.groups.find(group => group.cause === "upstream-fault")!; + // Two rows that differ only in fields the fingerprint cannot read are one group. + expect(fault.count).toBe(2); + expect(fault.firstSeen).toBe(10); + expect(fault.lastSeen).toBe(30); + expect(snapshot.fingerprintVersion).toBe(FAILURE_FINGERPRINT_VERSION); + }); + + test("first and last seen are a minimum and a maximum, not a scan order", () => { + const accumulator = createFailureProjectionAccumulator(); + for (const timestamp of [50, 10, 90, 30]) accumulator.add(row({ timestamp })); + const [group] = accumulator.snapshot().groups; + expect(group!.firstSeen).toBe(10); + expect(group!.lastSeen).toBe(90); + expect(group!.count).toBe(4); + }); + + test("a snapshot holds a count and two timestamps, never an occurrence list", () => { + const accumulator = createFailureProjectionAccumulator(); + for (const timestamp of [1, 2, 3]) accumulator.add(row({ timestamp })); + const [group] = accumulator.snapshot().groups; + expect(Object.keys(group!).toSorted()).toEqual([ + "cause", "closeReason", "count", "fingerprint", "firstSeen", "inboundProtocol", + "lastSeen", "providerClass", "statusClass", "terminalSource", "terminalStatus", "transportPhase", + ]); + }); + + test("a request that delivered its answer is not a failure", () => { + const accumulator = createFailureProjectionAccumulator(); + accumulator.add(row({ status: 200, terminalStatus: "completed", failureCause: undefined })); + accumulator.add(row({ status: 200, terminalStatus: "incomplete", failureCause: undefined })); + const snapshot = accumulator.snapshot(); + expect(snapshot.groups).toEqual([]); + expect(snapshot.unattributedFailures).toBe(0); + }); + + test("a failed row written before the cause existed is counted, not bucketed", () => { + const accumulator = createFailureProjectionAccumulator(); + accumulator.add(row({ failureCause: undefined })); + const snapshot = accumulator.snapshot(); + expect(snapshot.groups).toEqual([]); + expect(snapshot.unattributedFailures).toBe(1); + }); + + test("a row that cannot be dated does not invent a timestamp", () => { + const accumulator = createFailureProjectionAccumulator(); + accumulator.add(row({ timestamp: Number.NaN })); + const snapshot = accumulator.snapshot(); + expect(snapshot.groups).toEqual([]); + expect(snapshot.invalidTimestampFailures).toBe(1); + }); + + test("an upstream-supplied terminal status cannot reach a grouping key", () => { + const accumulator = createFailureProjectionAccumulator(); + accumulator.add(row({ terminalStatus: "prompt text from upstream" as never })); + const [group] = accumulator.snapshot().groups; + expect(group!.terminalStatus).toBeNull(); + expect(JSON.stringify(accumulator.snapshot())).not.toContain("prompt text"); + }); + + test("a provider the user named themselves does not enter the key", () => { + expect(failureProviderClass("openai")).toBe("openai"); + expect(failureProviderClass("my-private-endpoint")).toBeNull(); + const accumulator = createFailureProjectionAccumulator(); + accumulator.add(row({ provider: "alice-personal-key" })); + expect(JSON.stringify(accumulator.snapshot())).not.toContain("alice"); + }); + + test("a clone folds new rows without touching the accumulator it came from", () => { + const original = createFailureProjectionAccumulator(); + original.add(row({ timestamp: 10 })); + const candidate = original.clone(); + candidate.add(row({ timestamp: 20 })); + expect(original.snapshot().groups[0]!.count).toBe(1); + expect(candidate.snapshot().groups[0]!.count).toBe(2); + expect(original.snapshot().groups[0]!.lastSeen).toBe(10); + }); + + test("ordering is by recency then fingerprint, so two runs agree", () => { + const build = () => { + const accumulator = createFailureProjectionAccumulator(); + for (const [index, cause] of REQUEST_FAILURE_CAUSES.entries()) { + accumulator.add(row({ timestamp: 1_000 - (index % 3), failureCause: cause })); + } + return accumulator.snapshot().groups.map(group => group.fingerprint); + }; + expect(build()).toEqual(build()); + const accumulator = createFailureProjectionAccumulator(); + accumulator.add(row({ timestamp: 10, failureCause: "rate-limit" })); + accumulator.add(row({ timestamp: 90, failureCause: "upstream-fault" })); + expect(accumulator.snapshot().groups[0]!.cause).toBe("upstream-fault"); + }); +}); diff --git a/tests/usage/usage-ledger-retention.test.ts b/tests/usage/usage-ledger-retention.test.ts new file mode 100644 index 00000000000..35363e13853 --- /dev/null +++ b/tests/usage/usage-ledger-retention.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { enforceUsageLedgerSizeLimit } from "../../src/usage/ledger-retention"; +import { + MIN_USAGE_LEDGER_MAX_BYTES, + USAGE_LEDGER_RETENTION_TARGET_RATIO, +} from "../../src/usage/retention-contract"; +import { usageLogPath } from "../../src/usage/log"; + +let home: string; +let previousHome: string | undefined; + +/** Rows of a known size, each carrying a field no normalizer in this build knows about. */ +function writeRows(count: number, padding: number): string[] { + const path = usageLogPath(); + mkdirSync(dirname(path), { recursive: true }); + const lines = Array.from({ length: count }, (_, index) => JSON.stringify({ + requestId: `req-${index}`, + aFieldThisBuildDoesNotKnow: "x".repeat(padding), + })); + writeFileSync(path, lines.map(line => `${line}\n`).join(""), { encoding: "utf-8", mode: 0o600 }); + return lines; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-ledger-retention-")); + process.env.OPENCODEX_HOME = home; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +describe("usage ledger retention", () => { + test("no limit, a limit below the floor, and a ledger under the limit leave the file alone", () => { + writeRows(20, 64); + const before = readFileSync(usageLogPath(), "utf-8"); + expect(enforceUsageLedgerSizeLimit(undefined).kind).toBe("disabled"); + expect(enforceUsageLedgerSizeLimit(MIN_USAGE_LEDGER_MAX_BYTES - 1).kind).toBe("disabled"); + expect(enforceUsageLedgerSizeLimit(MIN_USAGE_LEDGER_MAX_BYTES).kind).toBe("unchanged"); + expect(readFileSync(usageLogPath(), "utf-8")).toBe(before); + }); + + test("it keeps the newest whole rows, byte for byte, including fields it does not understand", () => { + const lines = writeRows(200, 16 * 1024); + const originalBytes = statSync(usageLogPath()).size; + expect(enforceUsageLedgerSizeLimit(MIN_USAGE_LEDGER_MAX_BYTES).kind).toBe("replaced"); + + const after = readFileSync(usageLogPath(), "utf-8"); + const retained = after.split("\n").filter(line => line !== ""); + expect(retained.length).toBeGreaterThan(0); + expect(retained.length).toBeLessThan(lines.length); + // Identical to the tail they came from: rows are copied, never parsed and rewritten, which + // is what keeps a field this build has never heard of intact through a compaction. + expect(retained).toEqual(lines.slice(lines.length - retained.length)); + expect(after).toContain("aFieldThisBuildDoesNotKnow"); + expect(after.endsWith("\n")).toBe(true); + const size = statSync(usageLogPath()).size; + expect(size).toBeLessThan(originalBytes); + expect(size).toBeLessThanOrEqual( + Math.floor(MIN_USAGE_LEDGER_MAX_BYTES * USAGE_LEDGER_RETENTION_TARGET_RATIO), + ); + }); + + test("an unterminated final row is carried as-is and never promoted", () => { + writeRows(200, 16 * 1024); + const torn = JSON.stringify({ requestId: "torn" }).slice(0, 12); + appendFileSync(usageLogPath(), torn); + expect(enforceUsageLedgerSizeLimit(MIN_USAGE_LEDGER_MAX_BYTES).kind).toBe("replaced"); + const lines = readFileSync(usageLogPath(), "utf-8").split("\n"); + // No LF was invented to make it committed: the scanner's definition is unchanged here. + expect(lines.at(-1)).toBe(torn); + for (const line of lines.slice(0, -1)) { + if (line !== "") expect(() => JSON.parse(line) as unknown).not.toThrow(); + } + }); + + test("one row larger than the whole target defers instead of emptying the ledger", () => { + writeRows(1, MIN_USAGE_LEDGER_MAX_BYTES * 2); + const before = readFileSync(usageLogPath(), "utf-8"); + const result = enforceUsageLedgerSizeLimit(MIN_USAGE_LEDGER_MAX_BYTES); + expect(result.kind).toBe("deferred"); + expect(result).toMatchObject({ reason: "no-boundary" }); + expect(readFileSync(usageLogPath(), "utf-8")).toBe(before); + }); + + test("a row appended while the retained span is copied aborts the replacement", () => { + const lines = writeRows(200, 16 * 1024); + const raced = JSON.stringify({ requestId: "appended-during-copy" }); + const result = enforceUsageLedgerSizeLimit(MIN_USAGE_LEDGER_MAX_BYTES, { + onSpanCopied: () => appendFileSync(usageLogPath(), `${raced}\n`), + }); + + // This is the defect the revision contract exists to close. #5063 captured a size, copied a + // suffix and renamed over whatever was there, so this row was silently lost. + expect(result.kind).toBe("deferred"); + expect(result).toMatchObject({ reason: "revision-changed" }); + const after = readFileSync(usageLogPath(), "utf-8"); + expect(after).toContain("appended-during-copy"); + // Nothing was trimmed either: the original file is exactly as it was, plus the new row. + expect(after.split("\n").filter(line => line !== "").length).toBe(lines.length + 1); + }); + + test("the published file keeps owner-only permissions", () => { + if (process.platform === "win32") return; + writeRows(200, 16 * 1024); + expect(enforceUsageLedgerSizeLimit(MIN_USAGE_LEDGER_MAX_BYTES).kind).toBe("replaced"); + expect(statSync(usageLogPath()).mode & 0o777).toBe(0o600); + }); +}); diff --git a/tests/windows/windows-secret-acl.test.ts b/tests/windows/windows-secret-acl.test.ts index 29d1b7bc3f9..a5a2f379c54 100644 --- a/tests/windows/windows-secret-acl.test.ts +++ b/tests/windows/windows-secret-acl.test.ts @@ -636,11 +636,11 @@ describe("atomic secret temp writer portability", () => { test("sync and async secret temp writers use Bun-portable exclusive creation", async () => { // Bun on Windows misinterpreted the equivalent numeric O_* combination as // ENOENT, so every pid/config/oauth temp write failed during ocx start - // and on management-API config saves. Keep both writers on the portable - // exclusive-write spelling ("wx" keeps O_EXCL; 0o600 keeps the private - // mode) so the O_CREAT bit can never be dropped again. + // and on management-API config saves. EVERY temp writer keeps the portable + // exclusive spelling ("wx" keeps O_EXCL; 0o600 keeps the private mode); a + // count would go stale the next time a writer is added, so this is a set. const src = readFileSync(repoPath("src", "config", "atomic-write.ts"), "utf8"); - expect(src.match(/openSync\(path, "wx", 0o600\)/g)).toHaveLength(2); + expect(new Set(src.match(/openSync\(path,[^)]*\)/g) ?? [])).toEqual(new Set(['openSync(path, "wx", 0o600)'])); }); }); From a2d35a609e679fc03f197b53a93b4c7afb052e5c Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:31:42 +0900 Subject: [PATCH 42/87] feat(desktop): draw the app icon and menu bar mark from the brand artwork (#5355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped icon was the Tauri template's empty rounded-square ring, and the vector source added for it reproduced that ring faithfully. The artwork covered 21.4% of the 1024px canvas with transparent corners, so macOS treated it as a uniquely shaped icon: Finder scaled it down onto a default grey tile. The menu bar carried the same ring. The mark is now traced from the alpha channel of the brand artwork — cloud, braces, orbit arcs and dots, nine subpaths, disagreeing with the source by 188 of 262144 pixels at 512px. The chevron and underscore are drawn rather than traced because the artwork engraves them (alpha 217+ against a 206 body), which reads as depth at 512px and as nothing at 32px; they are cut out of the mark by a mask, so the backdrop shows through and the menu bar template gets real holes instead of a black blob. The backdrop is a full-bleed opaque square. Apple asks for a square, unmasked source and applies its own mask and material; the transparent margin is what triggered the grey fallback. tray/icon.svg is the same curves with no backdrop and the orbit dropped, since a dashed circle resolves into grey specks at 22pt. generate-icons.ts renders it to tray/icon.png at 44px and covers it in --check, so the menu bar image stops being a hand-maintained raster. The tests require the generator to actually render and report it, and require the tray source to carry the app icon's mask verbatim, wire it onto the mark, and draw distinct curves that all appear in icon.svg. --- desktop/scripts/generate-icons.ts | 26 ++++- desktop/src-tauri/icons/128x128.png | Bin 1865 -> 4985 bytes desktop/src-tauri/icons/128x128@2x.png | Bin 3971 -> 10519 bytes desktop/src-tauri/icons/32x32.png | Bin 477 -> 1034 bytes desktop/src-tauri/icons/64x64.png | Bin 864 -> 2357 bytes desktop/src-tauri/icons/Square107x107Logo.png | Bin 1482 -> 4091 bytes desktop/src-tauri/icons/Square142x142Logo.png | Bin 2035 -> 5556 bytes desktop/src-tauri/icons/Square150x150Logo.png | Bin 2120 -> 5913 bytes desktop/src-tauri/icons/Square284x284Logo.png | Bin 4419 -> 11897 bytes desktop/src-tauri/icons/Square30x30Logo.png | Bin 458 -> 955 bytes desktop/src-tauri/icons/Square310x310Logo.png | Bin 4863 -> 13130 bytes desktop/src-tauri/icons/Square44x44Logo.png | Bin 636 -> 1524 bytes desktop/src-tauri/icons/Square71x71Logo.png | Bin 982 -> 2618 bytes desktop/src-tauri/icons/Square89x89Logo.png | Bin 1273 -> 3389 bytes desktop/src-tauri/icons/StoreLogo.png | Bin 732 -> 1775 bytes desktop/src-tauri/icons/icon.icns | Bin 83642 -> 142884 bytes desktop/src-tauri/icons/icon.ico | Bin 8289 -> 21175 bytes desktop/src-tauri/icons/icon.png | Bin 9291 -> 22832 bytes desktop/src-tauri/icons/icon.svg | 49 +++++++-- desktop/src-tauri/icons/tray/icon.png | Bin 3433 -> 899 bytes desktop/src-tauri/icons/tray/icon.svg | 28 ++++++ .../000_plan.md | 95 ++++++++++++++++++ .../build-desktop-icon-set.test.ts | 68 +++++++++++++ 23 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 desktop/src-tauri/icons/tray/icon.svg create mode 100644 devlog/_plan/260921_brand_icon_and_menu_bar_mark/000_plan.md diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts index 1670eb53518..f16e8179a4b 100644 --- a/desktop/scripts/generate-icons.ts +++ b/desktop/scripts/generate-icons.ts @@ -58,8 +58,20 @@ const ICNS_ENTRIES: Array<{ name: string; size: number }> = [ /** Sizes packed into the .ico, which stores each one as an embedded PNG. */ const ICO_SIZES = [16, 32, 48, 64, 128, 256]; -function render(size: number, out: string): void { - const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), source, "-o", out]); +/** + * The menu bar image, which is the same mark with no backdrop and the prompt cut through. + * + * It needs its own source because a status item is a template image: macOS reads the alpha as + * coverage and paints it with the menu bar tint, so the backdrop has to be gone rather than + * recoloured. 44px is 22pt at @2x, which is the menu bar working height and the size this asset + * already shipped at. + */ +const TRAY_OUTPUT = "tray/icon.png"; +const TRAY_SIZE = 44; +const traySource = join(iconsDir, "tray", "icon.svg"); + +function render(size: number, out: string, from: string = source): void { + const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), from, "-o", out]); if (result.status !== 0) { const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); @@ -131,6 +143,10 @@ function generateInto(target: string): { produced: string[]; icnsSkipped: boolea writeFileSync(join(target, "icon.ico"), buildIco(icoParts)); produced.push("icon.ico"); + mkdirSync(join(target, "tray"), { recursive: true }); + render(TRAY_SIZE, join(target, TRAY_OUTPUT), traySource); + produced.push(TRAY_OUTPUT); + return { produced, icnsSkipped }; } @@ -139,6 +155,10 @@ function main(): number { console.error(`[icons] missing source: ${source}`); return 1; } + if (!existsSync(traySource)) { + console.error(`[icons] missing source: ${traySource}`); + return 1; + } const check = process.argv.includes("--check"); if (!check) { // Render into scratch first so a failure half way through cannot leave the committed set @@ -174,7 +194,7 @@ function main(): number { } } if (drifted.length > 0) { - console.error(`[icons] these do not match icon.svg: ${drifted.join(", ")}`); + console.error(`[icons] these do not match their source: ${drifted.join(", ")}`); console.error("[icons] regenerate with: bun run icons"); return 1; } diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index 2de806b6fe739e9c640b0262b964d52626ecf0fc..b500b7b11925165b02b336b272197df3b74a63f9 100644 GIT binary patch literal 4985 zcmbVQWmr_v);scQeu=DJ3N#4oV3k-6=>2(&EtJNJ}$-q%cT`bR!L-gb30| zcL;pj`|JC6pXZ*5^JDgzz4qE`y|KelTB_Gb=tv+4x~6tt=@GaG{`(NYz_;ep!}s7u zXs)5E1YQ1nWi=FFAc#UhO-Vt=`~60`uMUOkS(o@66=yI}j@x4;N@aYvqCz5vRY&g_}jARHtbC5_F~u>asyW-4u=QT23-2< z=}}GgD|9EZ%KQ5I8XGgF^>f__fAi)|YkOBLGpXDc?;TjGq?}xUscG}fOolv*X0njN zsFQBo@GW@;I3N?>?cx-HJoFriU*?Y%aMn*>GBFs@Jm!EI({Oo9Paq)Y(iH%6g>FKF}fB-2K zU$VH%%zH`qZSOAz)`THo4b=RGuUkyBR@>UzoSd9IJUm`Vo$t(dG0VQ9k@NrCn{dm| z&u_UeCATGjJyV;bd2e}eFkRZ~?9fR@rlGv)!e_BJp{%UTqANzX(jZ8F`*%lVZ*Q-| z_p;{_46q;s6Vv9>QvO@9TZ4s&z!uXyELO(vbgZ@2e7-YUR#sL;MFm-Ijxw@bSsO)_m75!KFIz`X&(P2iCH`GpIm}s9093Ah>gUo?tsi~=PadD-(;FG{a z#l$uzKNUAMU3SN_Ya|Kit{4a;-9ivSkkEfFl$vi*Q4yR`>UG!r=qQ*cv+S+NT&^7+ zh}q|O+YA;Y&xPsl?_XSEW?}+=-ROHH5Yl2=VSta1-}-`&%E8LY%Ee`Cef^8$gouWQ zhLz#B;o%D3qs_y^!-WM)<#J-q;X+7KQZgkaB|AG?TU*{|Xm_Hgp$UtPZE{~8$W}zZ^7A84%H>I=VBzLQt7AJmI!;bbdIoxy z_Xaa$G;)V2KUJPD%U!D7zb`+XKuQqIt{U?$A%T;N>z>Vbdu2t~kZslD0Isj z@R&8u!os4XPkK8=)b5eJeNk>Mdu5vfLQ_i%l$n->#%Q1A0}9x?ckkkQ7IIZ%{rCFC zE8WG!#NOWL5KAl7^pIcxZ_l>; z`Ce{gV>6g3XOfcGtUv*yq^72)rG4q+(>u^22w*{(nI=X?X}kUMQ5hLbq4%CtT86JXGRygY zabJ$vj(YOsNk3Q-xO$2bZf2F1@=Hpd4CkumP%0|6eN7UKWtL;ICidE#4v&e^%fE)i zi|xvkbXPgVQrx(~w%U2$7KJ&RWcUj%3@rkIdi*$~L?yaqeh2jN?857q{nN9v zem(j5@D+3UKR&}1W>|W3^Sg+tCJ05*$hUgs46nIiV*N1k*C{p z_h)+Fb1L^TWVg#z{*OIW2^kUJie#3 z2naGVnv!&1IzBncmM|6)6H83HiiCdaN)gqwtVo#cF7&W3eJC$?adHZMk_GT)d!1BR zT>RH(k6V@1yn_opnCBAjryWu8rttS13T#m3pdcR~rC(@<1kZJ5+1DmZ&CP)Z;-CG` zeX3fm%}#G;?OmpcyNIM{5_s_Pfzh$CvDCWO&d!@_Yf|O|pekAg6TdD3n7o;qQ&50T z%*-k)gU2MePFC5$;qcZ$SP+Yp)G2_qh;eK(^i)6`iCjB9_>=Q)d)w7cICcAsnRRt_ z)!ZU>@oQ3jZ7q*ZnEEk*@G|3i4*rd|?d|PhQCa0CO>kmj?JB`X@(_YRjpN1()I*LN zR~I5<_|nxCF<|+Kj4qmsD&_a=?Dj^@FQj3NIuAd;-|j;Aw4<4s znSz2sPB~zf$xja34hsIy%_`LA)h(jb zCGNQ)hqip+1kOfUS~{{(PgnQ+@7~~uNG$Db!W8t%2v)=VXJK8PNd^&-b2W#Jc|Nc$xzP7Rg8lP9E77-*Lmzs)9q+wtv*(ADS z*PWpeA%o=OaODwONu5JR^ZAC-F)NZ7Oi%Vu^X1Q%#dg~zM=XVa0{{&}E+*CQSzP7eT z?vA7|ld$&osz0*RY)N9fq&?OsD^-d}?EZ9TV;<0Ikx#JPC2N#Rm1k-1?BwF+ z{>o1g(y|UJ&G9rbGxK^^w%p5I%en$nTU(+D5(c|H*Fq?43%cp}gF~Ff^!)Sy0Lqsa zt8dpGi}gxVrM(11u>}R(f`ShZwH?MwCaUZ(00O4Z9KF4dzZC=MeMxe%3PB$$Drkin zPYp(1P!kv@LtTu>??q7^=GoTDtf1o(H`dZ*d=Ej30Od5d?*=qdb#*mBDJt3>Gd8DK z?iJ?mRnX8f$C!fPwK`JMzW)Bwl9DW5AYiBkpS|;YmzI{6BTY(6Pk*ZlqCvN*MOLdm zhHU+Q{`~2-_w`P^y97f;=H+n=2O{Oja_9AR;yU=YdHOha*bIZ)VNczodLSLil2HvWv4V&3QVB@560 zJ=rs-Q&LgMvF%gn>%nj*37QC;s&io?BO=;&O@)%9qR86mHCT?q9;8d%;p9}V3~;ow zi+{DyN-Rq6R9jc4FenQ_pvr3un8*lmzDLeh)d#5FUmKg7{+rX16}2ot?DTX+ytdYBiz!^`Q6pP_ zVAs66ySvR`27ltf{0@`ss~^99$$75-up7<)uuwEI^o;y2lgr8OqDi~hG7#JVDp6ZC zhQp}t=%ApW`uck7wMT>?1iI+_3f%QVwLq{w^_Z9;lC2W^%A5+uFfNlX)?8@n$wQ zHSWt=bs;H06Y1;f`T=}evkfsM0Fnzt#?Q}x<;oRvIb~&KUR-^FeZ2K@(8$!3FCZ|` z36UpAozTxxuj|~G2?+_Ut*s*@vjGfiBy>?gxRC{5egzIeJX>02kPhe0`3ouIbbaYx7 zcX5U#g#-yk7Tj(!?Cf3o{A6?xaAJ>a7Z|m;v1`-nZ4J>vMuuZQx7YmCiFb0)- zL?+}(Z{M<6$LL+96X`rSIzoqrHuxMwsAF4+)d7Dn(9j5pic*G31Cx!-%ex=rFc;Ec zUgP|ej+~rz%0m~0a$WyX0icF}fPkfpR5o==GU@B0^RLE+($Z*kESrCdJ}_BsZX%*Q z6u@e6fkJV^uLk9a0kg8PwYIj#eL`uAe)f0{w6dioM`Q=kofnzYxZ(zqCbt|9u zLacH5l}J?^)?p5W8GspJwHG%72bTB10C{QE3I<_PbMLaQL%2H%@Nu0vv9y zi#Z@FEG)3;YkexsO&Af-C_wvxsDV3YyAckgDDqW-y_c8Q^XEIEq|_1&F~B`5DG^kr zztJ7wxgp;T=!1lWL|sDz0O>!v2Nq;=Q^0~jQdE?~!bwwK|NGB+FPE7wY6YWRQM5Sg zemdaS+JmarOvi{>EL~lYz*pw(U8N8WE=--%lo%YX z$QG}ZF)%kbmzbE?+1UxQvAjB3z(+-jZhPtN?d{{^oB8yQH%6nnHjaa$_= znURr^iOJUdJo_Z;!fZ=OYm(f`%F5W-7!fl)IF$kM-erpQ`ge@u=H}_ig&^=s-|OX{j>lkZiz3-=bT8U!RK6aj2-y$NSg$fz$xBI^ifb<}Kv8ZcsI& v6%{3T3_t!g`?dDrCHj9jWcq){QDA`jVqZz|3hlZT1sX2OoRUqx*BU) literal 1865 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 literal 3971 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 dXZJ4xegWbas002ovPDHLkV1l*|-qZj9 delta 446 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 LNkvXXu0mjfu^w>W delta 836 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 N002ovPDHLkV1lv9#3uj% diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index c5f321fee56c517943e638299436d2d18aa42401..ac18887b72ef2eda3caf7770cedbe9a828454f32 100644 GIT binary patch literal 5556 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 PScHPCicGPTq5uB@CgrC$ literal 2035 zcmbW2YdjN*8^;&5Nx2iTmRh+kbexdUTqaZqDVMNZjx@H(vC)$4G&C}DNs{z$rzYZ< z`!$Op|6KYr>=+rALG!%rwjf`ELbO^aGgNnJJw z92W<-a)d4g) zk4y;PgYPh}K8A7H)QSbwDOp(f)=+{;Cs{h*jtVMxI(&DOZ+3Ug8x?XWOa9x@Pn<|y z{BPF!7ODOr$|8;O)Gd$+fwv#lUN#e=dVo~;A%RsqbZ_r8NKnnB0YB?X_7vYCsy~_R zj+*MW4a+;epgzA?fvzSclg0!HjTp2&OS;`NAiwSxKiWa$)709i=B0E(sG>YeX zF7>j|30<51)Uk>s{WyekH7QJAIBfPnO%OD%?KOA_){*1g9V(cd32Al~9Glb)m&WOu z#lOVpNE7x1DkF^3>Uj+rb+w~&BtsAU3*;#jM6`eTo9W)`85Js_PwbkKiCSb{(GvKj zbiJmAc0k!J>sAO_V`5)~sB&h5luOO36f1W^dZb$6El290F?_Z142uEhTjnMsscB!6 ze!pXi!_2Y_1u*`#R!PX}D|svS6eT7WU~wjnGiaJBI?C@CxWipKb+qH)8(IcW;|qXW zG(=%z`QO<&wa?1YPb&wKug3`$@jWZZjf~X1^WGq=8zd?o)>=4h8?jEWc+6iKJ#cuk zG(T4)6hAt+OzX0Uz&~YpRyS_+g(&ZUit~%UjuxOr5=~66BG!K_1<@C6$|l-;?(&(7 z-s=~9itl;1{@yA3Ci-kU!s~^;^ke;=w%oZRT&aUO6AK@b!0*j$4y?-t_8O-v*}PSQ z7~wI)byP=c6|-vxqTYV+1P7RMZAW|c8O2L&n|ndsVP(gGh7vOr@`b)t(uTM9@=Qwf zo{PtjvFg?xg~fTj5+Pk?a}kdQ^S-09g!kEcV5}k_DY8!;Y+v{4<5o1yNOHSgYslHHJ|gXW7i2vfa_Cu2;e*X_s>cKfDUMVlnx$ zRY_fqR#${$J0wDl48T$D6Aw~l9k2g?tsqs`$L9M(|AJxrJM_SPME-l@=8pl-#VqTk z{B%V+&dPSLaey8%?jGfRNut1V3aoXbC5T3d@T9i>7-PDKG=Hzi4Lj!4cUtS%`H9%a z$_D~gJpg`B$18#K-K8^1a2x;g(M=i9T`tr1eS?+da8C9v7%*q&PDJF1 zUtVnct6M3e?QKWxiG)x{@>i(#%n|YuRJ*W~8~64YsIOn7Li+J&Z9EMl)d8<5;V=}Z zCpW(i<#G$issmE*I&xP&{=(jDk?)j*x5w?$)mS3>atDsPnH1}|YWc4BZc4AELlvn{ z^kpOG;4&mbeC-nu)AbXtBt&1%77p_PG?!pY@D4t`e^WlmyfbGDWzC|mC&(FxJ*dS5 zv=@#io3i1*c`~tnM0l-9{DYwN?6L27$>qds*(%mgWX(Y;L7kv00 zF101gk7SzZ=z;xr+#Dj*8+U}EGsxJSJYce&LzELGleF3 z<$jCtyjeEW@l2u!;ZsnfM7%Y^o}eBYGGCJBY;RL74Z;;Jm}qGnceNYga))w$rIUQQ zm(vLI;R%s47RI5v|NXALtUM4NctfFmLuC#67)okiXPkdHwCKi7Kk@w7z046Vtj>#N z%@r&ZKixN)>QAIt2{lVkxeexJGd3Z77k)#gxKb_u;r0j{U4dKT>O4mhDhtwg{dS!3C2W+u!}N<~ZvU;mqK z^Wwr;1FO#0mlTx6SDXDt6mf;n@w8v^V-@x{{zvZ9Sa2-#(w@&yiS3&^esbWM?kIYX zV_4yK^&Wl_sR^GL5OL1UN1zh!>rIAeyJ-MJt@klygdxmjx}e^heYUrVK< z=u`CyH~)@=u9>xR^zu|#hn(tEW0m~cjKsFIK_Hnx(qc}e_!iA^DE8!NCa-^mbe5w# zLo9b+b|bB_KuGU%X4MUcdJ|TI?o%14i|hi5OUP`S5e$ARF>8gJ^5S0OhM!hj+g?z}dkSS!Rd&;~(Wy^%4L8 diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index d63708725193e948117f12a9d460d499450534aa..27fe4bca5b0a7bd68b910efc0ece7c08d8581818 100644 GIT binary patch literal 5913 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 aSgwvQBJWZ&%`(io5$ei1O79h{qW>S;m~|%r literal 2120 zcmbsrX*3%MGX!zxi=$Qj9Cf#l_#r}`uf;Lr9OY;+0UQH-dQDHUWnFEBJ6tXUmSK!6 z?oGPcXO^OssF|qFH^#Swom)9APm}KkY^qQ5#LyYBm>2`YF)^G(JkWm#d$%xhUW&lXLo!vXC&uy5TrMs{*o6jU=|Jc^>!rnZMRkO_41V8 zZRzo4qVo9NLW2ZmfDdFwU`ANaaBWww{v&rf`~b#zcVzT>FE|Dipo`5Fv8Q2b*y*<= z{7Vyc%N|psCd387;2sa2s!dbuvD%{dx}MV zWgoQ040#<}d5kPQd!^ybTGvJnty)0z2h$$gveX=pxz+J*GJQD8gdT?t$i~3Nc*FZ8pdj;Vb)Yp3oOb!gcPAK!i^X#D#ak; z%74|aO3PaDGXI&*C3?>YkM+Fak27UqkKM0Am5v>UI%MfXU*Pp*{%_}gT?{8UNv?;bS2>YK z(m0ZbP-%&5s=0Lb5kfUpyigX~(bTc`Y3|GaM)GJvaIl;Zc8AHS3o4}rNNt>;%!xg?ua5v9Re94b_y<(H+A>Z~Bq&D$9j|qn;6<3g{6|?K8 z&GD?sXEkes*p6YDjXa~H@+M9m>8XF$4l?R9h>=en)lV1Y*84ZUwa;WF*2dYG+azyLv zXnex5Nsv+cV1BgvEPAFwST6Mf#W`P;Mi8?PTy7 zA+bFtezhp@LK!$|KH8DNnfMB@=D>zLn5DfKOr_BcX^I2<9B*jG>r)gV%{(^4k*{A5 z$`iL3```}&_7TXgj4|L?4}snMsYR!w zxD&7()3x=^D0+5$S=35PanWX)38390?e9iLyvHos6&ohWwe~<$0_q3Qn~gOy>fQ92|>X{1XeB&53rhVE2i=mx9vu zOV(;i@`!)ue`VI^$0HC72o-r5wBOtHG+${7rNe{!#9Q)2u|?L1eYE@+Ue#7wg>dWZ zFJyAtp6FQ1XftES$`Lo8#AdP!$dEdr8Q4~cuTr78m0O8$u=sw;@%0BWr}YOrI5pgX zA8HI1cdyrqi|gOz1qcq;!~TKZB1 z!Q)5Q$S83x(6Dgg;s`3EFXP(A7LZ)-{D1QY2W)>J<(dq}X6k@zKc0NR7)BS%kczqN2UM{o>*xk|@Bdo05f7Z2MPFQW!D)bYsZB z@elXErAu;gax(QfIXPvB`}TEq7PQq?R#iQhwc6cnZ*L!@ov8J7RaK2#i0zo)UmbP* z+4T7I?*To(B_gIHe%+J77(XIXhLekH^VA|;>fuCxe}A32x3{;QU9J#HBST7(jV-TB zMOSx-lvz>PsS=CDZf7a8eD+@b{>}tS5=oRFc(C#3&mU%`mq-ae{-hqvu9B=a;_KNL zC3Xt)h9EIX$EZT#zIw8tb?fE5KX`a7oJNJk#XY^f65cDr zY2v7J->)<-1QnRK(&Mtu1?pBM>S4_hK8P;oWL`luJhlWt`FSb-`5~`laiwO zhTm$U(m`Kezr?UWkO4K2pOeGF%F4>a^X>?lmYkgY^uJeHn%Un!>qY`GxUZDG_E+?< zy6@h-!+3ai#xibfY}mBFkhyVPGPYo8YoRMXCnu-3x0h`u{nMvU@$vE5**0b7^2GDp{l-yl|aS7-45kx3=PYFFv47nD8fvBbe|u!2&wn{tvMfW%IiCrqw>N2 zD=n0QNobd{gTpdZ@X+8Oi{~k>S*?%RN4MF%<)I%{P92FDReUl^Jr7S$W@hHp&aU3x zwJ`eNH^P{d^z@r1p)$A~+gt{vNO2!-aq-%1|J%1e)#Lqjh`RSU?Qo14qZHbYg@Y1QwojStPtgzPO1aMR{dFrZ$Y zS9@z~E8KLu>n>E;-rgP>|K9d>KJ(U&4vWuTh2@oN>+5ic9Ga`ex0KO5%v4d|(!{S* zQtr>T;CFRBzKDPP_b>5Hi8l%@3tBQVGS1G<_V#%>*^3^Z($mwYr>BjLjn^jf-Y}r5 zq){l8p&|9`gk00_-6U=!K`1!Ro+?fiP0hZSH^iY=S?-sVlr%RtL(Q~c^&+p3jr_x( zZ6JY*{pz_s_u}#6#~B$jaB2hMcVEssI5@y@eG-e(=b(T$_B8%G ztF>wg?e12c>+kL5783G0+?ri8&VBz@%+nrv0vd>o$X~+T&+l-o+{WmRDoPrQLr6&Y z_h9p$*HY|)LQr=C2O}fn-Me?;Vm}jJzs5}09d*CvYGn+yf{KB*d%#|>f#u}nG_DWW zpYMo?kh8Iwhq-{tRxEdVUBN&VrS(>nl$4aw$b(wk$Jx*CV@gL{K~(`vH)?8XcK5Iw z@Y4qm3g9r1DWye40o!v5?*F~uc#Vu`5Sdw7GIDZqy1F0a!iZqL%>?a6fBNJfb5oMy zBzI$bz5|Mho0pf3sMdG$CM9KVUzyPh60|5uiqTjemscK`nU>gYI;71kmcCDC*u>5_qo zqLm*${HPDCDJ|vQ3oW&1yh%sbS*0B}oXXJ@}mK)}h){>TJll6=SMd&(Ur3KrFauc;zxiQJ5` z5n@P9DC&cmkZ;wf9qE0A=ktDUZf*eqcioqM|NgzcPFNFBSy>5iA@VJ=nV`D5`qisf z1cDUY930y_JDl9yxQGXijs&U_Y#c4Lc*iafl%d7$CTt=mqHb{W5$Lfrs5&$knXdvh{ShmMbp zL7ix&-PQAVOmI=}~KczB3d27p@a|_!kb&$H#}iL{}6ov8SM}MK9paNBJ4VYa zVki`1)6&*gR*p9t|5aeZnV!fiye7Y;Db&SZx|ooZ1cmyoyu5rxg4%^t=p_4@GK+`1 zyUY|iNlPmik#3YLcQwa9$+ zZrr%R#l__>bzOmJ0QxL-?fB(`CuwO@(27~&ouk9UFpEbU)&{N$cu@6IwF=fF$rvXbODH0Utb@=(ngM(q@ISx*H?h2Hzifi#J`V>NCuyL zM4kvX2A|&b-&u%@Bg>=HA)K6=GI(caJ996o1b5$bR^bm3&;-u zqz2@{#gQqNYypIip})tAA75%-A4#E*2DHljqnpg105>FHk9Kj}Gg&JyuRVA-5J8^6 z?DyIXD7*Xj0|NsaAO20W_Gdz;It+hhR`C(WOt1vv?sde_7k;}2-)yX>nJs4!tdb>6)F*?8!!&lb8tm(Inco*VaqgHm0Npa~)= zxxx?Ce5@?c*5#ty+$95_JE?xSwHXbilj6gs~hn#HVXKNJjU%7IH&!QnAHxxK! zjraXoD5}jLHCl;^SxskaGe^6>N8gz!nTPiLN&&w2=8f0J6Sd9Tqv=Lh_HKyj>FNFMOAFjz83Cd%6>?^(um9bV;X`PsqOozx^XJdG*SjzG zV4K~87JHL{{Ke^8ZvFfMR0bZ?{eV7-j%}yW($nn@dKlq4v)c4xo=g2%K!jvVv{}f~ zBm@66J-!&Xew1?2-8kbWcht(pR4vPLG!>6=rEM2ko(O4+`w2{b;M8&MAD=wvi)EAx zKHWE18lj@3JUToip}|YFv9yd$Pw&I>G4*Vsp+jOS)pYaJxA*t$j8S4@VlFN&Sy@@i zD0R1k4Kp_T&tH3cvDw+Vxj|$E&q1&}oo4qoq#B98rDZJf?U%>t=_w87Gk_cuQ}d4> ze}UoHR5IQP%j&u*m2uDMJGX-Y%*{UeYziwQ^dOa+(hKS@56})M)T^4BgMn0|7P z@|gD{`}0o>nzdKPs6%0%BaT&s_Crhd2v@lK95ZX*<7hjS%C3Q>HI5lKm6E(XzW~j1 zhTm{i^%ot=vPEMzMm}(GB0F+(MIK2lht2yvc(4S&oYjxY#CbWdzR zdA<-Yhdcw2LS6BdJZ9DnEkrUfz3m@hglpc0EShXIJp;Sc>xaPc`+7 zj6PYlT=HqOc!tYn8WvlS((I*GWz#`)^lXy3U<*j`lDDc!fDG30!uV)msp+RDgKF#) z6>DFZ358Gt6pQkDK+NoVsEiz?rNgeEROrqMH|Ja*KCG`)6TH*{+9m682KKT2zl94C zPZPN%NAk5Yi0Gx%|N7v{#Y)IIFk=ooT(sKk&9y>9L$ zt)~ZuV7y#*w;BkZtkJ?Zl9Z`fCV#I1Dm~C+#>TW}Q4>wA5#&jH7Q8A4K;}PGRDe9r z)h^VbBp}Q8m|)Kg}n|an`oB zxb>QC$g^?|&Nz#E^=%gAhHq0+IG-vcP~W!iookCSscW6z_Su|%2Vbm`usb{}i*fI6 z?y2V36nB&cN~I_C;!@w4B+NJ01r7pnR9fnonwysg3eH8kKhfj`vXCzn4e`?}?aRv3tS=$FF^wL+8gnoJDXfv*MYminQouhtLfTgjl|u`_SeXNbCEBOd47~IAy>;|WE zMxx;h3M|Y?-0wp=B+=5+elIaMye8YjBwE{fFB(xSa{^|@@^m4J^4 ziHSOUJVHW3IS20OINf|rP$Aa>ssQg;bQ!9uszBEOZ}0!XDii!48flrWfg(hQk8Iz-|;?4&^NKzsIgP!JIj z!E{`()6vxhg~^oB+qy#nEt?z@LwuB-)-k_6{z1vaB-Nap;xA$N+j}ADb0(8HD)E;H zXj|X?-@0xLwAHWo?7bfg0B+tUC#&d4 z!!H4Z1bJUqRP-Kf&C2b>uzmUd{d-qeghOCLIIv_H-)}>sqgu+!4@M$hoV>e9@FHsE z%Ts)os}xA0`(O}(ZSku7HB<+UQ)UO4GVH=PsHr>U6Vl_D$Rgg1DNZ*vF$fDM)-}ep za0|?1ndqBWR#)YWobEfVCV<8U9-XGzrI~j3YsS5d^Gilu=M#KoKGf9osMYt1wvSLe zUb}+A#ugt1L4AF_U5347Ik~d3awfI%=JK+NtC?r-3 zzWy3spr}<9~)6`o71`@Ct;**kv9LQR! zOrE);P|1G-VC=&1urrd&l)O&-e0UfgdG94yenuuHw=pplkLC}7SXqwv>@0NYQ?~uy zH8C5AyWy{17(*x0_w8FuT`w?K5zK^q?G>@TWtN|!4PeM{vWg-5K!qn)D@Ann_CDK* zi%(6h^gW1d6N>fQmQbQ_Vlu8ZtUvVbXvPlU8&Qms%}I4)ZUMysPk z*0nPUV|qzUkdBV-gE$T@f`Wnqc`x{_s*0MLTEOEsoGLy()!ak^^!I{U`~_P0d%@X( z_V=LPSBX8?9c*o75f-inK{`kP_D+A4J)MN#`)fX57-8(pZQ8Vkld98YbN>|Ojaoe} z)Xi7^KJyS@sIKmTfhIB4B}P!~pzsE<2i-kA%b3p+ww)y7Yfm0&v*#?0v;igB~5J+goK1d>Z*g0k?X-EURrm*fQLs(S^wrf z%px&ApF8+1?_YBn=)72;g6U98SI^G2SKS-`hppYkUQ}2?fvikMT*?vX?#@5iw6||B z5h0axZ50(mgW`fYZ|n&-Cj9Z!u|U1be~mS`71P1oRoaPb%3Gu7YxM+tED$hoUph;K zhq;nLcH`6fKJYyw{-ttrxJpP${t`?2Z21KzGWj$vKK|GHhlz2ruBv7eElE>C;sg`A zU1T@Q^7EC%Lo7`MgoI2#x-q_vlg;91WPA)$;CjH#c3?~^T%2*6sCI)Ra8x?e)k6tj zzEgy43J3`7bS)I*^pn4?3@ba1jLb@pWDpg7<6!U)z)CAsI9jy}ti#w_n$rD&S7loo z?J>7^H5K~7oC9-LkhZ$gx{V-me{;ItgeU^_nl|8vbM~x-uCkO^LdMT5D*Da^J*uB!r94|@vI4n#lI9SSy_|i)4@tkPG{cktJHRxz) zzNU$fwzq=_X#T3qOBGdITnw5aXk${0P51$G*!1*%q}Ykge=KqC-W?&KJ($O!r9$-> z5tfibP~2{WCPG?wcf!Crzm0)&8NXhhcsnc{^dx6PY z>g8r-2?YLe=473z55#vD>4P(+rKWBwiY5Th{ocve)1V3LjjHI-5RF%<1wcA36c<4^ z=>+9%{HP(;@23cBS)TlKJE3S|c&TP4m@A|DfotR~Op)TlK+ZT+a&u%P7VF{R!R}(F zfCqCx-^Vi6L~wn!C2Xh`Wvta^?eegxw7|>TJJ;+h7jURfV%vnyccDLyfSFs}PhLk)N~!KW#8y&&Bf z8`>@)9G#qiv@+@|zXDw>R=IP|ern5$id0hy#-`ps(LgykIjODYLDi`7{qu}!nz|A{ zIer4fKpj^DnoFrK?VQ_scnA)?a}#|FqinENL`=+R!nfJQSRwItzyUCfwp}18BsFEv z#AQPMtQk2>mxD_NgZpViDh~p3w>t3J0;&d^+)p4*0q0*`#MaeS%k|efctPh%%|~xy zQ&s9cH)kCC9!%$R0V*&g<~&^|#>9kGk?lP>337FH4OsRz$t@}Zmu(5esycf588j=f z1_*OBi7>!&W7^F?U!GelRNN)m*>Z6PrbL#`>xpVxKsIS^+sCp4-3_ zejfUe&pAVG0@wp4?CQ!&I+&nIN%*5QWn$+XjED%VIAKA-C+VRVI<_MtBjNpKpS{3? zmLZC{EtL^6*BT+L5fXB^#mK|M!@&{3B8q{E|MKMv|FxVeJy4W~fB)7TdyV$@FAe5K zfdSFb&;V90XapE}1tJ3AEPTJmCJ)}s<9T3Xd@#@|1GKEk?C zMuTz&&L4Mz52z+7Az`eCBA1Ymz`1zQu3D3AY$l)tAZ&Vi8dJXtm^yqQVCnmWasUZ7 zwa>co&i%PW9+Qb8b~Uvgu&lf6X>rHSaTY|2kf89)NB5`JDlD7|Enrds3uVD`LxKYn zkh&ryEKGG7ckNh*g#B3o|Fx^c#ICCWc>8-%YkB(eLRo-lplVt9bS*4q^4{DfCntX_ zz4cXOrqiAlNg<0yAi&*u|DGT8%E}e#oORcJWI*}d%WUA{k2O3DEdH`<%9|9hqsYZo z3>HwVb0F7!3I%xAwH7b|_I_uCK)zKu(hOe7?fZQ>hPP{rslwY2kB@csR5Ao?6pQWr zB&LC^K>GA+vrP*pb#{-Z`S&h48QD2J0_|B_TLX1_$Kqre$hy2d?*1XxyguNIFu6<^ zIK=(|0Xp1ktE(oara`mK&li(EnwEkq51$4voR{n489KM}vDi5XKY)`#atC< zW$>XGRdNG!bE)7Hzc#NvZF;y8I14{Mhg%pL9&SvDN8Bri8YnN{7MTeeDm4?onlb6=Ejn4+)8l<{zb%GZQW_7r$J2$>b_<=aoKrI!_Etx=7)S~tun02BCk`-M z=jZ2*X68-P!Tg5EsQ2sF@oL$1Hy@vuk&!yj@QnPiyx3-bX9!cAO|lf-M3MCL8PG+| zUy1W63VP)65Sax958$^Ch_s2o6SM=)i2hEeN5;BQ2yPh6Nptg?Z9BjO$l25_hgonD zKmTqvYN@E8OcXBUZWMx{78F!hUHykXXfb?8Z9a= zP96bf_=Pr=UGS@b_L-cSv=y+Vq@)0CPZsmctFM<#Q11bEX#zrKpwUqIwq?;g35MYG z)(0M(r#%RW$#ive!W)Y9AkPX0rWmTB>kbLo+qZA?G}8zKXvW~P!4;42YA!4+9L^@8 zAY{FHb^%dWUmwBn1YQXV6p_BCdqY}4%IpStDeuG60P>lipMPs|F95hKGX-QZc?_>m zd)h9jW#IMgSHO&gObxvO1~z!};`M2%q=-ime`mek(q~9KAkKb(MgC z=}gLVQQgN!xQ$LyQj%liS@eTmNQSe*_^0apZ!+Qv_sZNfL1(R*FZo;n9CEBFfsu#Rm%Rsk6?xMn3Q(peqr11oWhq{1x^7Lth z^iO~r2rdz^`_9cb}SwaTSCYTq7hLEisZi zNlQrZ+MJe*6oLE_LbRe(x;q$CGqc%^ljPLY)Pw~2lN%Hiwvc#~dsJ(a!qwoRgkOJv z?11R4@D~4v^74omFVG8H&UlYI=D#N2#alCf#P1EqO*Ow=FBwW2+U1A;!m$!YDmBY)+1zNk$--w5J z1MWa-D&6+MdFr~mTR7u{!JI;W3{7*GRe}Bc?2V)eJ4NWTXCxQzLA;<6&-SIrh*aBU zrje?!tn6TChsopC_R$HHLybfKllenn3B*WIZ3BajH_@u?z%J;>$uAZQBRft{PAV!Y z5D1ubw=~}}Tw3RRTC@)}19>e}B<%XT}dHO+7T+$eez z8!OGODko7c&3t~n~r~cckIM|%o94?Hns`5^CgrWVuyc}FZO|ZEj zgC6!267^i{%?LieqL1M-uQRN6Ho{RJE50=0q>Y`Inbr4_z#@2`YYXG^4#Xb>@u`+~bCGN9UP*&#p;K3LS zI^faDp*oNuL88i_t|JtBjRt3+5f&UWGc$X8)uB?L1*|ydU$PKZkx(KS`1rncbc9iV zNQQ`xg@q(5np+2rUikf6>2R5yD4tCNc0b5VnoPiOi8QXTjkwEBP@tuy z5E~y4`LBtY86kpSNa#g$H0unr84;}1T)z%y`pqR$3nF_$I^h>LC294aTbKL6ZO6TT z2`SnoeEgOo(=RAKI476sIvQ$fWqEwx|BWi0>F~}kNlR;tMz^Z2LSO`|0>d?d7)oRZ zSYB@v6BUtn#l>C6EAU(x5}C4npyWX{A08c%ZW>731O&ZC&Ho6K4H&c!R!|I5jOp7y zWzZ=4?~H%_{D~;<1Au|2Z&B+LE$ZdrK|oAwP&}!(!p@0k0@ez=9;^t&qn=-(&!0n% zP>!KMAnN?KnIP?CW`4RnkeQqN3*sZtvjhjf&5ewfH#fl?c{t|=Iu$Yxy$q>HX$=KD z1cb@wZQ<@NaGpNH*F(*Sra2H05Rj3P4Gs-)ls-a%{AH92{rLGatms@gc0Gj=0n7oi zAbR&D0GiFsvbDlj4>B9z4#8VIK01QjaDy5Y^WEZO>VRoGLu5Wgvr< zC}H=x)~@(lzN_Cg{!FCACna5n)0N0UAb?IoKx~C&Y*Ts%Uk;Lb_$EwDqzD86DPU3! z7Grx1xDLn(FA!ZxU}iR=EG$C$Gi6{p&@}v;Z(7Sh*4Bt~|i`xrtw@N1&;q zq5|=|rY4a)h{jLQIlrdfU8GvM!z1@3J9{2N5Y~I)FJ4>&+kZtRXC(IAQUY2}rVveg zhf_?9hJzq8z32*P9`JA)|DB1uhg*$75_TRp3jB^Bfb}>U8XD*-Ririb;_U3~@89_@ zy1xgdv5t>n69k4NS+ODF5qMXw6O}57(%gD;gSl!jkCKAK01Y91ay%m&!DT5*guw=@ z46M~y1jOyakZpqLA-nerbr(3~N6^4b;ZK{sd|?z48g6ZUrcg{l!on#CuXC42UtbXk z)(kHQb%+~Qg!=$o{_TFdXJcc-y4?rLsaXFVguzy${B5`qMU=2b2TBf}ouWF;MFc!a z92}g6(?2FYgz>}>BHq4z`|jOi&{%Qt=fP-Wuw?fl7a!fMhH@$A?=# zWtfU_F92KE**OLmin1eG%?g4ZI>_I_QI~}4%sZ}*-%SxQ{oplC}T^d7{5(0fHMf~lEaEy11>6ag%E z#l=&HYQUl!jAW4d+!J|T=ZkJ7C2PQ#pql^fEypjgB=vZM+;(@T0cg?jGKGySWetsb zuVtP21P4>z(Y7`_Se=8c#d1KH6)!?nKb#S$WgN@PaB1iR*tLU59x3LLgVSD51-!=A z7JQ+{W3C2IKng+tJvli!?607y7+a9}DlP5d$>BCE5eapDcCobN4~GAn?>JuptFF}r zEc5plgWX5gFLuxc(0~wudB8VcWMl!M3f??SZP)_BPT}G(pk(qZDg>FC=O;gT0%Rs} zV@RRc#{vGv#|i1B>fq2R@&{Da)I?k+{Z5a)&*1^?nE2||?1p)Ln1L|U=)rb{T@s*~ zB#c_oU{JaH_@oPCXm_4J$0MWErJ$rd-?RfiStIm0E=)&Qk2@F4!ou)DNr@G8d3lSl zZ085dP4n@vJ^`6REL9#aSp@JRc+Bw6^A$!2tf_2vYQ2^PT_#1b|Im@X_gHZvBHbgS zq9Q&{LH$G9-{G^gxBt^Y9|Gg;PNNqD_u=8;i@%{{MWEQ(Zqd=wo}8YRSu{@Pge7Da z7J{iE9(1?`oeY*@vDKf;N11MX{Y6R;3f%t+QR(**__L6zO8G(f{1hKN39_W-xw$zY zR8$JyuoyzM8gzEz?}>Vykf6MSquf6-QU!NbUQQ!u8_vz=eR+ie)kO?KRp#j>l}SE8 zZBI{6*s=oGn+>yQJRyp0yO0XS4@Yypp#-)GbURG64mkeiW}JOxk4uFAC{kcKu(Pvs z@i!1WOIKIEkU$c&v}*yuSJ=Z$xt|MWgrH6W)6)P>1tk_T%yd^ZQi literal 4419 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 diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 85ab79627a6ced69d0ebae3987d5650f21b31941..904b613b47019f7434d5c67f78e78f959bbb72ef 100644 GIT binary patch delta 928 zcmV;R17G~g1G@*183+OZ006X7IrWht6n_J7Nklwh<$ym`H1y zCY3w#7W0tojn;ec;KBQ@F87@CIdjg;J$FdVT#3KcXzkn?{95wm3>&2y}7xeDC#%Rq9{s|WHOl&iNwsz4A1i= zlU}da>2zkZxu>Vc=kvY2y#YXDV}IipXrAW-fdEO8qobqp&h+&3<>h6ik~=y&g25m{ z=e0;nL7XT88#P;?!08kXw+}ymiwe`_j zhR$ZQ4u=CFluoCorlw-CSQV};nx+8&Aw-ho$G&|=3xa?U0)W-kRk?{an}0132QkaC>_zXDzAh$gnKCva-_O-|zK$2L=W*nM|c7e0-Xnot^9J>wi!vw79rfngO5D zvN(>jSS(9RO9uxB)wykLZOhBcPNx%N{Obr)nAaMOhNkJcxjBo)QibbuI`{YYola*o z8oj^2|J6YOO%R09XncQv4+eunLqiB5wOXxKt3Uc6gccSSR4Nt6anH}sEiEky$#Df8 zA0H=^NqL>m=i~AC*nij~jRH0CCyWKXMtxD_{ z=#ntT4-XHyT+U{*)z;R2C*-^6KhVGNAMy{Oe*iKi$oz-=vkfo+0000t| zNTq~+HJ}MB0&^ChK5!2l19y1~3xG=?O8J}vjjYfXFvtKMKJUP$ExGNl-T-#hx_=9F zfE(b!60-pKz#8DG_if<7R)Cg|R6DGE8z?6U{8Aa%QSk$yo+ME3=~fD$sgU@dJ5^K8 zx1{@0Qx#;3hWV=c$0@_Psq7B$mIM3>9I5q+3+yTTDhKqZ96I+1ct+VT1%RKGJ?{c* zMFoJZIb7heY68|u+PPe^<}ks1Wq;2W0M;k@(FN`)J179`TXK)cJ{X;k1zMG?vllH`S3Qo)&rvEV&XVy(FqgudZTr8Ko!W6rUht?ifV>L!ahQ Vh&5p&6h;65002ovPDHLkV1jB4x+VYs diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index 0276815ce95d826e4a528681c4c27940d395f515..f02fc8c89165932790df5ee50ed675aa2572e0a0 100644 GIT binary patch literal 13130 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∓d6sopJFhDP9XjvK|wfpcuVqCpcB%Gxv8w60K3iK;1s=r(Opd< z?hd5jw{(#>2r_1lhC&$O10dK%Vn2ZA$-kkc7J8n3+NMZowwp^x$u-^TC*_OPZ|r*< zbac9-75&&9NMH=xqrjC3ZOUbkZuZ<`)6*j^_-m@GMLqYJwWRLdt>IEh6<`$?r?q;h zYi!IN#s4#ul*zm$k)YhTH8UeawX&+D#2V-*Yn?nMga>7BUnVXA5IG-Wo0gp1-r31_ zyd?G)$$XvRONO9iL{G58basOad_ zppT)yl|(pJuVz;!vg4AAIcjS6aHC)Z1XW5zEG80weUbICT9)M5Vh17Iw}QDZ_b1P= z+&F$6*2|Iy1cl69-j-+E+}ymYk}O^M0(^kefXyECH+Av!EDD8MiFh@ajx&de>L4{g zX~DFQZd;ERw{DP;0b0msNDvSy#9u2;WC=Fu?Cdwe%-Dr^Wrnr zO($8OzccQ}-n%%TJCBc#e_Hlv3YtGD-zMvo`?eZLA=`%3jR+3zgx)7EE?%t6GTZ9s zx5%Z+7{kKB5kU~;3FFlNm_l0dNsRzUU}gX!zmO+P!M1H~*QzB{h(9p)kBLDck?mpR z>@20EwAZgodmC*)M*up0cN_!eQ+oQW?znrGZi%%u>aXq6t_P?fiw9doiMdR_4JWRp z(_?%HuMf#|M_q;Dws`uKVSUDwN)X!EV!{l_`%zNo3gwtU!IYGg_6`mN`1n9krz}v% z2G<1}JCK^+l2*(uLg4`~+u-mpp>xNd0Xko)yJ4bDN5vID{Z*{U z6to^@UaWF}9tb+-opFC0LJIJuUQbJ2H8nR!kdet;yx-Ff4@Q?G=9S~aaL~{lV`o<{ zDbj}*c6J==I*OLxl0R!V-Tlw63Cwa$Ao$t6k56`om@PB6qoX7LucA2qdl)X-z3@zH z>*_3KZ!_b7$&tQEtbkn+o%K9dcQmhO1+ z3;4@5T4DbY7{6E~B{L6R+(9Cd^YK#I@gQ2fe=jU5@8awXVEX#?YtUKVv{=||Z)gFU z0GjF({oFqWhjMw>!<<-trqcx&8I+vVKegnvG?@66t&zQOd9oWfUif+AZ*LE@i7j9& z!Bx!PQuw=OXP*QU;$VFSR|tjxbWM8ivJ;#v>4dupFI^!+K-ZFLhG^Z0-E-Msq7>-c zP?&Gts8w7rZaKsZtTO2+zj6_{e_uje90cpP<>hKI{Mq5&nBy22Wc!!902(ZCGaM`| z3Q9`3o^EBOrC=*h_@14RS{oW08ygt>0|Eh#GmJ@>D$z*L-~TIT5h*KT+(w&H)x+aZ zdoLaw9=4wSF%S|GT9hFt2m&(rI+xW5K|&G*>c)J-@zJiChsO~_FQ54H(9qEFQj}CO zr``_=X1ZlEknAl&&tC4Y^nr_$mzRe&hI(9g*TGiE0R{_ns>`8mb>GL^8~*s7Y^Wh% zg6K)RMjWu>6@Ye&mw3W%o5|X5Th@N^;pUruFV)Ei9?dT(0QtCQ@E8Hx`i=1Y)zYxjAjkKVdWcSYJOVBqZ-f@LgOHctk<) zJ%P}?EiP7gnWO!y08v1QS)2#0+MwR5vSKBrHn@mY$&2O=ngwm4y__$VmHz~et-OufpZsJQBYm}hx@u=;~m*lXTEyQa>N=Z5B#^7vqcPr}X90EfG1iV!T zfEf1XO`^^3$58K?nQFl)7|T@D)Ez(uby48@2g%&_6&|WIX#-n8&|y{_=FHJqsi{|E zEiBgo5Wx8>ivTK0ZFy z+~D|qbY4|cRgLucB?or_`mnC<_>Uhy#>NuE^3AmeEtr*XKzh`^;)czfo0k_C9W5>E z=4AfhuTebi`C)}0uRWH32%IK>d-1oE_IShGOHXq0O17+vt1EOx4i1iF!Ph47k8_zG zM)=R51bBIPAYo(gnHrdA*r1(+X%|zLFeiB+&z*ka_oehi%az;-Q%pIg$i~FO3(1p@ zQu_5W`%|{28=4g+V;GQNT;X5H(U#(*q;cTpKwd$X4s}pzsIB$h?-RN>-6tui$Gl}?ZeDBJ z5e6siFDw9|T_`xIeVKe+;hEqQE{F zl;9J$Z}&mXfPGUiEQ$e`vBmISpuoi&aD45}arO(k?_D0qlHP+%YwD2%VdWZe$pbHqv$pLU)GSqG0Dc!05C zzoD8n<>v74@R$;Gm121K`JG){@>%X`F1ah@U|@JS z;e9t&ZaYDP%*AU*wI4}={Oa%WC9`KkT^lsJm3gS$fg#_1>aI>u0YJ^Gzk-bs)cnDs zAuSRw|Adb8z`-8>`==#95Xe%9gl&uYr6s2G^sKC`)KsO@$Jat`P*bzYcG_n%h30>c zj|8fH>Gw&!^zeWJLJ3?CWEm|~Hh@k>$@v&$-5W%0phBu;h{{!72~ED#hmD%zgV_`b zqU!7a0OptIvB0GD!rh&69Q}P{1T_6xnP3XGmCJyWlP6?|xrg~t!$|>(#Mm_1nwV69 zWfVq0!AcILg5dla92A5;1gZj6`Y!sU9iazPFfpyLMzv}x*ut@~jNg~z?y3@ykl-cs z$b!7JGLWS%H!xmd+zMvG(LRt+a^ZkrMrA}>B^bjBHjSO_?Fe#mMc2l_Kv{Eh=4$Fk zLtS7ZfFzagZU8lI)^%-n6?8wmD_5c=uZXg|M}kl6w)x}sm>!If;6B$Dv}|o{EiEm9 z*j>!45wku~Qv}m0xrB1zdpp6GU2krIp{pJ73ZH-rpiM)_zPro@vj;{1@W1Du%!n8Xz5u~-qt@Q{5e{HrirHyrK5E(fq|PJ0qi z#o+327fe$EK^@<|sgQXf8G$K*KDRgkbBC|5Z$3%DO@ik2;cJpv+w1GBzLYvzT4EkM z*>>)80M+(W^<^VUn@ZQx`oK~=0{r{BRS&BK3txg3IRs%|f&7CjFevRJI(Q=579e-g zd97D@dWmt z920XXuyvSdz%XcBXOm!;c@=mGNIfdb%7OEB1AO2~uvP2PN_y*&m2WLBF8=+JZ^w=`PbZXpJk(NfFK*Y)6>J!rF|Ll$AlDh48UEJHVP*7Bpm7pxXHL3zX`7 zr>C0YKo7w7uzU7Q;duk>KEdRM6@byk!uAmfkp?D45MJ-N7>1OWk1HQ= zyHJIVCO21CxZ(jy6R!9N=6y)<4}KRCMFB;)}G#8b~d*5x@WvZ zw)XZGHJ8FKykLeH9vg#2x5~!G#&*i|fmMf@CY@^eC_Z4qpvMae3jTD1`>3>=;Oq7Y zd)KX&Bo< zzu(*4O-c{U#U1>)pVx+TG@2lqj*W(3C&E?|&_tp(*u?t0>;%e8Pp#2KK!@qX#Khs& z)L)3nvzBSFyXeI|G-3xLIj&%R1`q|V%q|JUbrLp>hF88kvbm%vPd{OR=MG${p}sy0 znF|U4*k#T~I}0jPhf>g=^59Di!6suC@~-?pjY2{ z5&#w|6ajLJytt_e&h!Kv)clLBQLypBq=A;G?eauNXL);@8{ODYR~Ly!WBQmf47tIA z8gv5X`iF%oDU4`*`7kI$(5k?F8DY|fY5@)e1CXXm8ygqbpTB?o_E#d09503Vp&^i_ zvrXS|;lca`00h9ZC>lx?tgo*R+g({6b(*5AoSdpKoT{rQm2%;}fEH1xnzjNpzRva+ z_QQ0`9_ttxaZ1)UG>{My%I?&EMKBBv3_c(3Sat%5oF#R>8@%mi=)9m}{02G6%{<`- z^k*&^3kL^v*Z!ExL<8dnU}900k}?VEMkd?X02ZB!0M5WYZE%>Qpohj&20r=h2hVSh zgVcefJ$f_-A_#0)!Ri9*cUnEz&D)v(44DioFf_cTSBT@jLA1Vp{R*oOO8ww!G6)M( z)g}3LVxrwbHCR9&QB+bQ>ykF15`?8NXs&K(^GD)z+c3+61P`1LD?s`K?if_<@bDMY zDi|WrCdsL(k0DvXP6ZsE2S+qUCoBmc4jTXJ)fkRh*#3id4_fXd(V7fwb}bASC|~wM zj(xx;_*|NcpLPMGkdQk#)UgmZ5tefvgLTS|yYmJkFriG27_iLMAb>DW8{ODh0`jhf)wa=`k*f(LQ6BXfW%xa$Ay zu!-tMHH$K!s7p#q!GUk~KCb>5QD0vl6&2OoEQZhmsFQGBQ30Ai^?@f4mw2+;f>1>$ zAGQi9$IFcfNl21}lT4{#U-Recs*vZNd4W%F6D*~{|3ekru>!#nXf7Pd!U8Kc1Ip9d z#zsj&;d0Pw4<~NpAtVg<_xFeGO`c~|XP06u8(V31cJ@{`?3R`rG^pKHo*uzgh^K|o z(HAy)A-YP2F+4;xlHTv(vfwTueDUtXvEUZ)~hX+V@!0c`lCl+jcGR)=#VR;b(4ARg$qbgXlj1+NR0lJ3Bt?I^NR=Ua?A5prqF8 zr}$0b^FXj*=atmEoFM26Y$X^sIxRzP!}Z;p9e|F3sR80#G_jj5k#z3CZ3F?p=w)+R z?nzLOTLjgA3b@9^L?ktTC!p-4+&pjqC~44D1n=Jm$45O?zz(h+!Q9bhOJ* zjjX#H0J(%vw7^PZj%)~9SuY~c9~qNfP;iHvdmGr-rCkd1T9I`KELMT#cDd5AUdI^o zb!Z4>7*KNyIuu;&`~X^X7HB5S>$625#mlF6eeLg81Kp#?k68L{&+nZ`eBdv64AK zlR~GK@;U&grtB!XZ2^`|^7HdyA(+pWib0BJNQVX7Za6I%h;Q!)e!;l>?Ti34*Z<~U h#rXgJHp!{f7B8NFN4{7q{BjJ2qMRDCT;}Pk{{vF>l^*~A literal 4863 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 diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index c6c3c302bb404d49329d4f28cc8858fd8cbf4bdf..5709ec13c39f43d38040848f1b754ba24b8cd51d 100644 GIT binary patch delta 1501 zcmV<31tR+V1oR7#83+OZ005EZ%~p{i6n_P&Nkl9C z6tr4iq0pr*O}A`4Ok6ihEsJ3I=O4bO-*0}unf>hS%x|W3v)k?9xP=@y001WtzkhWC z@k@t=Fc=IgD=WtUv9Yny+1dI0`Evk3TU%RGQxgE7wY9aqy@9OI6*w|R;YPH(WpFc-NMuvohIN{#gWH1=6 zU%y^iS^4qfM{0Y2e?LP40D#$SzIX55+S(d)$H&K?Jb99lkug6%&j_Ncs|&~Rw{PFd zWHRzSva+&@i;Ec&K$%RYhJS_zXs4&AcXf3I1_pL_cQb<6-rn~2_a|5T{QSI3CYza= zIS>KV`}glj8%5E8fB=id!UzJ#aSsmlo;cXf4@8pSY7 zsZ^pU`ryF>CJhczAe8Nr}VZa0QW_o&ELeS8_r5`ufrmT3cInI-OW7 zW_U!6jEqcAPeXfbY=3NOYAQcJpLY7tA*51ia&q#UH*cUT5{YODIF4f&1^_sH`ZN;= z0Kmk=1T$H%9(;GX0D!8hDu1~55CkbKEadTcWcSI* z$>!!}>VRj@o`nXETGp6H~>)6;W%dz*AQ91dxRgoFeK2lw^$krkcCOK^S zL^O!-@Nj5YEEcC_#tDQ(BB`ydRVWmhnVBgmDK?vp3@t4!rKP1UFE5iVI-TzJ?c3Cu zAeSMOKp=>Yj(;8*8EI^6{PgJ)oXGtlghHWGsqE?LK~WR{5EK+djV>)M(ehO))!sp% z*lc!1MFoN&ySuv_4(I99r%v~jtNR3oVcy=}D2nFfKo{~i?;Ra8{OoI@ZG zNTpKvH7yp)*w|QbaPVJ3003^>xbgDkORLo?lgVmoYM2}uD2hIQ{1_4w1W{jKpPQTO zy377|OsmzZR4Nq;1)I$V0BE(^Z{NQ8`uc8cY?POm(~f2~n_X5`cICSI8ymS?F8P)&G&?)n+1Z(slSA9yhlPNenVA_F7zhgsi;s^#b?Ou?>TozF zCME_32KapbwQJXyFXV$kKq^(JfBg7SP*Bj((P6jSg+gIeR1~#} z|0@WpR##Vdc6RptjrDIKjzs^9e`=0IClJ5%pMdxYs4#D0I@%R`00000NkvXXu0mjf D$RXKl delta 606 zcmV-k0-^o%3;YC-83+ad0016@S^<$E6n_FSNkl8I8siOoHvPEn8O%GFjO$-Cw8%oCA`LmlKFe)Dpv5fqV^uMb$^Hs z;aP>Eaa%;YD%rEbO4KF$zpZ1qNTz5H58*AQ64y5I0;|H_{9O?1l(5%lFpn#VF&nsv zUj?ypc`myWUvO9XKP@#)V%h&U3v?uyxs$y-X;VVgLXD diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index fc2f94a63eb8299c8c3e3a3dc2e43aa712c975de..99ab09e8a150a34a6ed9b385b2162b37adc42a15 100644 GIT binary patch delta 2604 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 diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index fc65ea2c5de6088ce9e50db62f1969d623c6b467..3320233fbdbac42605f9cfae5e4dff76565e3f4a 100644 GIT binary patch literal 3389 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 w|Ni~!GTORft*`#;f11}4)PV-D40I85E`Os7 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

&%V0&IXsJ+do8V^^YBCl3M% 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%0ALgm&A)lzLqVvI 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 literal 83642 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 diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index bc81c0166ab6bf4738469c6302e8f39472b65f2b..4ba7ed664e45e7f8f0440b2e50921d3c1f3b51cc 100644 GIT binary patch literal 21175 zcmd43bySsY*Dre0rGS)_Gzds{cZY;@mz0Qr2q@hpASD9QAPthzt%!6fNDD}agdiz; zChxb$8E1@dkME4J|Jv()p4Vr|Vy!#oHRrFcg&-&hCPGMvz@K!;01ATGBM3rA_g~L( z(Gi3j6+w7-{_A-&CW45dFepzpMY{wihSE8w98-$m+2s zrY6;t_3)5H5I0Z9lnQOr>FN^J7sxqS_Ud?gcs;4C%S>1{Z4$#_#}{L>DS0qw6hkk& zx4dr9?$I46AsYDi@D_fGZirUOX$cjw|NET+n@Q8!neO?l{vJ$E$?R7US_K)O&Nl&Sp+1%#DY9$E&rU z`&NGq#{;3vC#?%Ug@mJQMO#1q4MZce! zfBAjjF5Oa22oYKt579p74pBik#z85y8qnBKjc3P;K_fx zk!YP)H&P{f{pF}SyADZzLg-fkO8Z#4Nk#IK(>rQJxI>y%9=1&3t=NW`I?MS_0)x?m zxtKhx#cwv?n1zw9RSR6K5GpAdkVHt^j8RO!BY)-4zH?K;XTPId{N(k;CH2OlsG&in zL1-u2T6N36$e^^ew7p%5i#w|68sCqO^h)&>7Z;P5W|x)%Jv|GxS>X~EKQN{>3|sYK zPfZ%IV^58FZzC8vvE|8-NKQ`vVsKp=eP?g)@XW8$01v4#;vkEq$Ik!qW7=6TnK)X3 zHHMOc;!KE+DEjF5I7T7gqq(iVo&-NKeXOr9%%a+%Zq~WMgo6zJbP;;ML*9YQN*0S6 z9Q3q)?&t7sHCdgJLHYESr!Gl&{^sE!e}pymukGcwXLPC)_H~>b98*7@JhOLjC{7he z1SsQ%hcy%WL-Sb?4lb^{T3R<_-@gpfV}HMT&}P9MLKiM)?dm$R<=N=ru=e}e;5r}D zwBTA}^jd?(6qP*{H=(~k{jpfqxZ}}1Z=}SaTqJ(dRw_q}P(fWrDre*H40)l!k}k|I zDhd_1*fuNd{NO_A=)&kVumoHz! zURh}=8@j9;bwAI}!t0km#b^?2$yw3{p|&JwHrzy3+(bxld2w`0J)b;jC@$XE-Q8VV zv-9)&9;u9tjV&*~6RjXe*f%+utitqZ-2*jPrg+kS9=#LSN+HRv z-J-ZUjeFZSh<20q!r_D)H2~$?&9Vl*FoPUe-2YQ2fFXhK{HGJlShYgd>s2Q( z3jg`aU*(=9dH-m;v*9qW(4?SwIj<$(3lnGAzOq@Cco%G(P&)I#k4fltxft|e*0S98 zj|@~=m~bmfoVdeG(Mc=)=kJs34T+8U zQOS+@!`F{*Q1^ESs%~~(CoH|6Yh;2MQ}mB*tgEYQwLc-Pu)OayBQ7rP_sNNP)?J!} zcaN5&-pxr03u7TZ$2;j3)y3nEMMXtFzEmd^IM~%t6M+ik#MzT=kCtV z)Rt!~YRLBHCMy>gCW4L{oS2yS@+Dc?;P178x7PUF+$Nc3Z z(U+h3`gNPfx|$DX&S;ui5q5|+Rz`X{jxg^vyl}bBz<=@aOau45S{Hu&hO6a>kB|SX zeQ_U2Rnyni4as-3w4_zcjWSPEDnLPo>>mdN1Sr_8VK*=A7APl7I0{57pi1s7w#PMwIsQwpy<#o9YM0{r~iii>Y!YQt@_wzgJQj$Ck!%-&3iU-fzL;0}`8 z((a zhO0=21X)x^VpJLcSb{3k9THxkuEqj4l=wa zbjbtgcy1IG#&s-W5r6uk@>vfw-xL?;h`4`)Tcl8_z;FW_ohD)9LmXAuNsd<_ zO-)sqx2nwDGqkl`4MxL)ML9D+KV-+pZPr9bh+Tq;f`oMEPCE||4Qaq-3zyR^opNsY zpvjmvm_REXPlp}9DwO%~_x5xl9yW&?x4e#kxcCK}%H3jOS;BM;GFsfoUME*qgHk>0 zkT5xV>V8q`5kCPu6j}FXGam*RJQ6gIY6Pf+T*e#)VD*gcY;Eyo4&w<&qx4zv{5m{@ zHJM;N)9~Pnig!E)0F@}MQJWeKP0fR|KhmhENJ?s||LK0WO^x0C`wOk!)h#U@d^UPtD4@K0H3IlQ#I?rl#KHXY=EYeDPW6R$G(B zEACwzhYgL5pFe-rRrS;3jtS`ecmDeaLt@F>w>mD+d=Hl|8Lj8a0IS)~#EVl)o-6U4%?olCKjG%(*mv z`t-@L+9K}xb7%|m^Ja-E3hs2hq#OV8USB<^k*=K`4y0?{qq{FVE$y9cKR)|ErSJD% zGaj8BNniFig zW@d~UJoJ$C^z?_c@o&vgu;|B)Ay5d7Yh|b#?c+ z(x6x8Ncn%AoP2Eh;r+u)uBpk%d#0x9EXkvYN-J@BzR6tsKX{@pHV9?0K)YO>c^XBGeMA6~l zA>fWouU|js-kqMFHZ}?sEbw?>HknmvunZ0i@Cphl{v(4qtf{F9V1z3T4L}lmXm?a> z?BVa<(_f~4{kr8U3xnp{qN5`Gcy-! z>l$meYrWpP^T6I5MzaN{C_*u=5fGdl9rgA17sTx?b&d`Xn_F7;J8pjet^{kwh8Jdk zT^#_a;=5TM9v*UP^w=%#t4c;jMlv#8OQgB6GIT0;?^>9e^4I4@N2}V{*ccd))w*}{ zNbvHm?CiK`-_FD{GB+oUR)EzMA0OXR#B=R76B9N%YN(Q8uN4nFJNxnOLafjyyV~@0 z3iecITiehDKVlSG=>QH^)>k<>w+XQ;D=Q5Q3`|H{T3aV4C*vNQqa_biR8@Ug{pZUG zJvU4)k4I3j-E&LV*_mC*G&w(?!TMDV^hqsU zqfrrsD7p_0slQ*@b{5bLV1o}IKAie>BqR`gXz39X%*@EpV~0^klsWvFosx!TWOVf3 z!D>I5`4??dQz|N|*xr>F(gpcf4+%+004L&NVj&?RM}mDVL&Kw^Xb3emb^Xr6hYw-8 z&uvN~G7O2pikvAD-?=`0N>M=n5@|5Gj_~mF%Tobcv9O@=D*nLB!;^boHo|~CmAvvT zI-NMsL)f?C23NDSwH5l<2jGTPrp86O4fXZN)!A^FHCcuIz=o+FYEAyo-ObI4 zha5K?bb{)tDoVQCs;b@f!89Quq4o9kj6;kNI)7ekMMaEXJ%vR@0RAX$2aRrgnVxo{ z5T(Wsm)in-!^tTqgYexKp~m+Yv!Nr4&5`uQMBcuA`*CPUpzFud)6@OD&(8FO*ii%- zjB>GYm+D$t2{AFctRyg!$2IPF#7+qx!$^LyFDxc@zT6%90SC%*`M}3<@b`}|%tXAr?8V`+$nhDLEM!c|jUeGn!-D$ubO-;=5v0S{#QNr8L*2_W1Gd6Lf0%Zt*9 z*R|rt%*+fh6bRd91_#-Le}6BLpoj>}${9KdiTUB->jRISf~-%c%MGb^ojxumkcDOy zcXKcjb_KdBQ11Nv34>@HHA=xoP7W2Z0^0fa@?!H7(Bs|}m(Aeh5>;Rl6B84+WWm%1wWUI-{$zM~xJ4dX zC*0^2x2D@#*>JfI&iooo%?}}BYr8!jp5+S}S=ny*ArjPW zWrbdfDO;p_`0E!XCFR(nu%O`OQvytEk-)!x`ue25{C;oiFP!#bb6Ivk8+`<|RI~3R zaWvb>hKX&(mrjWO`}c1Ygq1`MUVJ{riwac#=nW16O)K#5`*n}9KlBm)HlGFtN~@}r z?$-215!#N5+TCA_H zpE3*{w#N(axvq|bF6ZUdXs~~FdSJ_t`0CXw#}a-e`(i9CENG2z-ky^d*zlGBeFG1^ zywD z5Cpmq7Z;a_cMPyU_??d*Rd(+3%uJl1G?-k%qJ+N%&^Ro587V_JH8XR1{BsVF3IPE@ zf&WYd0X2XiK|#Ue<70Ps_j3c147jAwhtJQ?_xJZ_W@a>{2W{%EqY{S6S^N990~UtA zK0GX#-13q5d$=*mm(Ib*M-*XQV>AyOe_^2=^d=ChZrrFz<#-kGkT?kG-5(p%wzRa= z)_ze?V5_T}q|Lf^cxF@6l$Y1t-yeCm_!0Bkm;0F*j`XQ&unNk{e=Jdb+rvGjeS7gw z4nRdvmc3!u-MTwg#b-AebzLots<5X42mMc=DF}WX^dHcqVRrl)p8Pk^M9g&sntWdW z%<|PER=(&LULj)+!Or)%r$C~J=22FH{U`y~sI*>VnD}%@X&%IDRN8*M!_=s-`>XpQh)HJ%3XvSHJFfwX7~H|H%UTwMoS2x>%cse^go~K>9JY;5GN%i z1yhN=`o5dn+uGV+L+Sh)EXgu-E=wK$laJm`qGe;2Kt0w3{qQ z3Sx&4&`79gX+3gwHfeHO8I}I$^6+8hXUhAb83<}{E>IOoN&opKcaRU@L&U_yGAh~u z8Zk1iP8Ms8kB@_XvBQ?nJ9Z7Kq1&;aI>Ap2!Se9g%5$oS+X=vcBJA6;LLc6VMj9_T?c*6XCaYx{daN}zq+whPR z5F`v|3KL_a_dTQ{Cyz=<=mZ)Lt<=xo|3+E?YX&jh?c0%RMIYY3KRY`c7#Y|&1Z_yY zV4S3(_Hsk|pQ?(A%zP>k258nmEty$Z@E6SseIo97RzMD9(reAIVzh++t-Ru33 z_f&#|cURgqoyLX|??y9R#4%ulEqXIvCF|Y3sH=yFqA!=>l!qGW}(jEHNs{ z&6`vd6i=T$`!oVtik!Us`tj)}{>R@nxVgDO8c5bLJ|^%;z^y-TiVmNx@>C5uPJ$ZzXKeY z#H>!zk_#L)CkM3h%wNMY&$F{>!sU!>?V@*F0NS@b-iY6S4pPoAoDn<*%2G*lD=RsH zGmRH0=ab0G_YS6k?j}uRj{|HaIzHau&2@H^g#H}S$4bXV#6(04N8LDpW@j58QYEVB zy|E~2$^H8EEAZ>hSu7;Cy1KfqjyQlG3iwmKtiD_G{4H@WzrTNsm9oEox8F3xk4%PH zn4Vs$(f$zwG)8zQSm8EXgD*Mj-o1-7{s=34Z=p4^>3Z2Z@GF>Me0=CwSn4L)(B#Sx6$?m?F)=Z3 z?q;!b_QIL~MHB9^mzS5XZ!6FE$Ve3c@^odCW&~ayaCt6>AmrCT7Z@EFz!}j2R50KC z7>)qgsGLD{%fav8m+NVkYxb*dBG6&h-9&n-L5ZDwqbaX_tA`64T~pZi9WSqi#UGttTwJ{pk;>sdzt*2NXvg=?Wj4PsRnxYM`ligkQ;;%iTL^KH zO0B8~MIb;aFmyLpE8~F55|(^j6D2I(c)&{qHN*;3`d06~7y4niplj6LMwiPCq&_gh z%tOY`?eN?VzA5UE{7gf|4gtvKQ78Mzf0MM7ke$#<;c?XqI7nzEjOD22%MyBdsC2s_ zgi6Ztv^{+2tnBRP`zvTed_a)_mO_z&*8++dI8(KarRYu_l&oW>vv=PUfZ1O?1 zwS1@d-xL;dm^FdTYHB(s`uO|l*;$^5IggOg%Sj+H}&mZU3a&)#cW2PDtfqI4z5F+ zB+bY#E{=Se1B41cKGp4Oy%R{UJ)@|>bii_;t;KvUdWD#A3A3|rgD91svcK=<#GA2y zK})~2wPjuUh&;8m zn27F&HT(ZL)yaGFc6O)EVKjz$ccImASHtucPx=t??Ykd`8?CxS9?msJcX&ZXg@@zB z_3`K|cM1v!$lUS(3+CeZXY$LZZf=AlcDmO<(IQL#w!FOm^W74=NxT}6JpW&7(et1^ zSj)=F=2ydH`P%Sc|AB0PJHR$V^rB1l+En&FxLRv5OC=>GytW_cST$8;2!ULHw0a^QqXp-p~ro3yPnyf6! zQ9iGbkO6CxNsTxikQPd=ryx!OI=%52gmqZ>YKB zvMXX!1xfK5zr2Y_#z%JBM~}c3taf~n)ia9t`(MJvH?FhC>8zdI+1}p1@tB?1ghMn^ z#!H%wAZ_rWvoqyEap1p8LxEWV;Q!jJZ{EC#d6}?AUr}C;7pIgY^vk@>hc~1L1?gHn zfT3p-?f9Mx6#*4O+~yqamx6a^adGiZiMJCSHHPdLJJrXHB4T0;Kf;9`Epm9V@seMw zxvQECJS^@NzF-$U{+=Q$GKJ6FOHuE-Af~9^SYJQ?)*4L!1HF4{N-z9_SDE71B#e~+ zIyu5>Ze>=)Gm34M;`f6065BH;1!d*fSt00udBifqj3yaujOLT5U7na*+uJl^p4fkh zd{Bem+Pueg1cz1p$u>!?QW>t(kB@oDniO|$%+g6*>yXZnq3iqbf#vq?K~Cb(?j5K! zm-Cn4q4wuVKmBFbTx{j&h&_u-?R4l?f<)~hq%Jc|!gN-b=f9z$w0UkN?YNX1RAz{K za|slI9RT9#vCf0f6|?nD$|e^BzqiS=SEPp(_wwg?M>P*^~l%;)08at z^=3S)q2b}mii%t|05CVXEmHhbGBY#t#qlVpsP5Dwwmd@F);|aX6fG=d8V_QZmZrTc zVhEE_ULNiF@!vGSdlM77r)U0teo7jFFdUAILtH4YvDQf~7v}_&m6Q+$eE#y~*|utT7V~GIxy#lhre%odiZs2ZYAe)H1Tar7+|qOgsT9F;rh6_8q)*I2f&rj zD~ufiM*_l5d37})>^9bC_*oc~|I0ynT{!fLclFQNp$(;il2X3oknGSv@@+5^xX#sB zl4E0H`hHpQyaI~YN2N}85~Z0XCdkaJSR3f#Q)`KHJ^{}bAS$0$kL7>Xt8PQzB ztn)o_z2Ep-`_t0T-Q9rQdC{6CIsiKZ@5T5G%;WA34Gl5fWQSHqa6UaU5-*qMGT)?= z9-~*M-3mHMQIVnu`{{OhHIr~tllLA?w1RF2!*EdlD#!((X>z_CSv|P&FP4_1y>@1t zCf}5D)FrG5e+QH%7Y2Dd>-HWqAyPNZu*X7zIp{}a;#kISO{3+*N@8pwU?BL zxGe{r_w-D$1_cLq15u))q`aFg!FHSseK<}jxizuq{1uONh}}*aIV=XyXFy9F)7D5W zc)Y(X`#aUu+1c6N{$*mKwXH4Vcf*OvNsuCHYig`{$lX5YmyiLizqro_{*?j)1O(m` z6_q+?9^cTZ1}-fkA_8cxqN0LX{iVih5uF`FAean^V53fqkLT44GXWL`w#O|#T4UQ! zBl#>PIhp_dCz1vsCE`Z9U>QOxD${%SlC!cnZ4EbI5$hQmPHr82{%nv~-TU$5M}UyP zH;RhZVcL8WW}&0A0%A~mh;4Zz4O}$)cmpg-z7HV(MTUpBKl>e{R@8%|2J?fOoSa8M zfFxWTNOn#=>wm78%ZB{5W@W&NC3!ytc+jFEzNPF?Z z4LBzn7Jh|!E1A0}G%_eeP`_W2H9>v?fElzIpxW!ZK_eT7@ZoA|YM|(&qM_Y*hl|4M zvUoiN-1+a1IxoL(Y}}-xDld#q9I%7_)9HU<;D`td2!MbLEffrBo?GL9#(a-=oLpQq zR8(GEM4+XHe%;*?cAW?N6e0$bmmE%C`4cdV!jE{yi@*B+y06wWqeIu~D?P zUP)oQ2a5XLEHP$IOiO=I&w;T0gmuv!f)V)l>^8dtt<2>m3LW;#loahN3$YUnFIYm? z+s5NZH!Pdn=!JzL7X-iLWc8LiRObLS5;6tssn1q=!YTOe!3e$&|2Y4e1AE@0*@OA^ z?c%z+!+zSp=TT8XorA3zg~@bL0tA;jK}vezapFh?lBR@Euh zLy(HfN=3XesldNO>mFbQE18~#3+TO7A>ah40b>{Wsx_4*7qDH?!wk z|Nd2{Z<&3vV=kq(QD+imDkiD5E?wXtD-UHNQ;dqiUmH^uNQ_B(Vk8&)xr#&7Up`Z} zLQajyo=wkrYX0zI^{>QvL;9hAM*79GzrrW}qfa^??0)gjTEtTF^%W12*~CqDMx!HS z4@M0dqhv*}EB|l*4`=XBsPX&XzrWuXb>s2(AvP|qH(wbtdV-#W3@FnPR+zrq1+i;e zq{pVFwzf9n8iib*{t@~yLgM!GYr}|^Gy)C}V4kv_JbrB?Z0zgT7`mvEIzSkfG=AXa zoMJwepuvAB`!|+y8nXl&ax3s7SRPP%Fj7h?E2k$X=OA}7mM7oY_G*CS*W&o(B=Phq zAbe*g&{Fb{Cs)nQsVIk6!!bcAlrkoGjy)Iyx%tdp zRdwLM#6u%f(>vSoK=i@*1P1#LK#SeK%uPI!p!%uLpDkLwIJvp23k%l<>j3kEKR_$t z^S0qX4|v{(388{ZXh#CV!louBTt;tYl$Djg8B$VF(Mmo$+8i$c^A`j?uC%#zRI*q> zkaK`#Xl4kgQc}@rBSenQ&Mj^$Juu1cu(P+k=Huhb;IZxr!y$$_|#Sz1qie0~UQ6Br$6RBKdk6l74M3R_yPatj-yIscBf?)A%JhQi2R-2{HzgAV!} zW~gjl_$?4tnn6hVwLS>pgrE87R8*OeUC~PCQ`{V)LdBp9gZly#n@ZT#GrKtB)vKWl z!5?#vItBUp3&w;F0Q!Qu1?@U7_5g0f$?>j?|L( z?P=XAcII$43_}3KFr?W8uHk{6awQfU7<>k?M;!lnZz=G73@PY)`Y#Ts8e~v{;3JC* z3jzIj|G5K@l#J744P;z^pOp`rARq#S#BXyfZ{+ZFr8n}?eAD=+Pcd@6Bq%0$unexU zK;ZsE(I_O4Q&4c4HV!Q;WWBf1c|f&`jUEC(?lucchu;~IWRD#m$d)j{tMc+>D<6`` zS)xdQxdlqpzdwfr=i`7M2L~U($SZ62Tm1al3t$~6it)|CfbPLR0^D9I_?&%{^~wzf z>qkBkKQB-`dEhmiD@elgm8QaFnv7S{J52r85>JWD*>vySgYW=6idHCZ{6vRD6$Lf* zAGp0otFTf)0^brP;o{_6XnXp&|Bnw8_FUt`y88OU&+JKgX~;;nWGkT2scIt->6Vt3 zAQtgE;$otA^*~ow7oS$@xfceQX@ESSj`ZdfX*_Qr3H@^r-2v)6mH^BIq~{~QgTMh0 z&;i?k;1M2h1YS?Q6R~E5d_7^;m1&BG2B{4cM1y4#^qi$7OLOzA&5I6yfXZ|4K-;dY z)C9F3iWa6R_)RD|l`sTBkAylZparY{7BO+JeO+lo47#2r0tg2nKAbLs2xYeWUwG+c zpOAyF69}?uC>DWs=aoBv7q?mzlsor4Tm;Vj5$#ZK0vRUI;?(kNC#NpRZ7xP3t7<$RG`R*L`xPEJl9&n1T12IZbQQWDq~tR~5)zt)xl z|1B_+X-L7O?l}8SMN3Ny?Qo~kdIgp?D1en!RhY=;_V$PVesK{I?FU5eB^Ah3H7|F; zL=5dVYP>=^gZm!-PLCdSLdAoN0P#Am>s}GvRsf;hw zWDyD%YVg2{T!doC=ut5@m*nDdohVe*qmU=8fHkE35X}`<4`{iNWJHkqh6XT_OLPj{ zoX%h=+%c#)!;12Lc1c;2Gp)kD5>Hf;h93)F)pFdZ?(whPdos^O?bv6K| zEATR$w85X)sP;8R5M>46>mt+&on8d@O=ac7=`#IIzpjrTS?}B#oS7l7{!pr@h_0=t zR}bkHbXiYN&!pt!pF2Bm-wmuDe6BD)4E%QzIFs3f0hJAR8B8Yta)t&5UN`~&h@h=N zCk8zol(G+Bzf!I;7yYZlJ8KuUA@jT0pv@cV+q4E-0SY zv9X&TZ{Q!lAwdCyA66M$I08xmm0hL3mnW85TBA_pNKkC7x{C`(0H$t43^+h;Akshu zEb~by5;C!}ZbBG$CR0}+f`GOIB%x-*4`t=$?QLy~oq+_%O!MPn$j4TF)(P6S!*cWV z?0eurcJpR&5`c)@!oog?#gC3^btF;4d(O3bb5jaLwivRBmrbHAV~Azsf5qi!{V(~Kw z>wxIaPKz>$F@yFe!7}hN^$PVY!>|K>d+UtPF$DED0_Xw8Wi<@E($cg275T@H`LD|2 z%i>}Hz$75yeG4rO4NuOGwg3V3Eyx-3@bP{6__6E#>Q87UaR0!(P$n#FZ#S2hFM!B2 z{00cXz?cjP9dN@5Wq^2qmF2q7!dzDS?FGF&^jr-yj;CH;$Bhr?u$|4ec!Y(e{Z4s4 zM4iHKSDH2JreM*-KoD?B>g<$WcZy=AK@}7h1`7Ms*VlW%VJRGd2k@V85&pZcMP$uZ z)s&&Ls;ET0%8{o`N=oWG9nBw^#=FU-p|0NR!}wgAEeN`8k$Rfi)`PS2-w#5DeEyvB zE?{;Lv9YqMsH%2Ppk{O%jSpNeK0e)DD(f&ll%4tJdwvg(MnJCBd@YiQ%@4aLNP@J- zmUWv4Cr8uymCs|rg;kzrO`$}|eNs`_c4d?Sb#B_b$AIktvjSQYcWj)THdq;eSJy6f z!I!ra;y-5%e zmLGJ1&o11e=`M*j_GqkeGZF~6% z2LrVUvOgdOkKKbC2%!f!JBDu^sKLu1-GcxGnvFtzb=j32p?op}jb0MYe)D6@u)SrA zXY|^BAZ~;VG#=7t^NWj8yu69I#hpz}KEPj5P*C=_(o%PJcED^{p8oorfKKe#3;;DtfREM>K!ZQ}@rB@I4ldIU$c*qRrf^kFy&ynGE3M|gdkLhmCGY-~c;$lP zcYdV0>CW)-3}TUos|nIQnp`_DKAu$NKEI_SEgWyl$wqIAl*G=ZEw_GRBgFRl`4x;> z<`X`lLOjP1g|@Nr@f*)x|6!)6RZqP#=r-M{3)F9*N5{m-5i`l{qdxiE^TUCtL0YCm z3w>5Hs3eccB6S-&{O4**f}DVP*&j>G(;2Mvr5%XEWq4DY2_zGMjdx@T7KVm|ptfb? zKO&&p=I$%Pnt@oqt1H*B7oD8!A;ULRe+)Wj?67t$5$|0y6BEi0N^~|p(3&kY#|@GE zH)rG++K}qGb?f@g3!I|HMuRE##)>gRL_s6u%r8KhO$FPX4tqjJgNo6OXr7egpaV7l zh;V7k^aUA?NMMH2>!Fo#BRcYQ%^2E-hFFn*3xjPZRPy|xeuvyds6|fc;~;(AmO=Ct z?FB7e^*k!@pHsMcZ;jCtQK4^!D z;W~T{7$o^4y_6t{U;T!p5R1+f5jjS#Qta-lnD((@t7~f{q@>daz6pRi06>Cm4%jPS zKm*H-Bxuai?043R+YSmuHgHT%-!(MAEWemw0%c=rGCnGk9YX|HW|J_=59Ng<7GY|L ztN{oua<9Mi0{v2$J-j?wN}v`^%DrHh0y(3k|_bNu{MdJ#w#~ zZ-e9#+xNhr%B&RN;R_Vp*tods@s-zUB#br>AWxRmTqB>205pXO8lFv*$|yqu@s1ZP zCuM$+-&UUrf}F&=cfzumtQ;VVi!=%a{PD@_w*+{~%U!MUf(fBXxJgaT;W;&8$LBEg zivHdMaD4kX$q#+nU{$ELxj=pjSNB`6z{qRS&{d^WU?e5H`u6s3O4f9m9t$VcNpYm8<(x z$^*HIiH(Mv32)wfhhu(U!3K1u=gXJ0v9Ax&ahE}$0vXJvQ3!S)+-6s=A}2(@kztB& zdFuBUAT6`S6+6Fq^JadW4(r&~Yk>#%C#)PCZU6o>^59DHbo1_2V2A~Ls(c2(uUy?{bK%kR-oBsMnGqcYy zMbvwj+TvszRR)YrELR1I2PO7(r6=Jlv$Any}vGrz!S2g0uMR@og8EF6T7 zC?j9`5X8x#kL%ooMj4Uon!}0`b@m0e*m1a%GWBb~+KzS)g5dy3nzfk%5vZ)ySD~;U zDT)HNgrJ5{D@GG_1)-oRWIa3kRRnWkJ|M7GOw+0mwrue8-}`nSM!+Q;Oti3a4Kyri z3vnp#(n$PUL{A!F4xpc96EY*WZ{Ow(FM^?FP+>%G$>lL_pH8BG4_*QMrmk#m> zT|dqHyxLl#G}P~)UVd|!&d$!B*<&EdM~5sCF$D!1%bXU>9>^R~?-4-aDXBg2sYJGb zdOqZA(5F50?cVNv0f{&E-%0+85BVMgE^1C84lxTjcQISdpj*Rk3v1)d`+VsjetfXc z82WRzkz@1b_pK{gx&N(u5u}gyp;kQtvj*3d6cnPK62Pc=a+fR+l)=vfE4cvvpLIaJ$rpyK=H@{K+w^6h~6 zE_c#+MKb5}H_~2+nLL3ow-|lE(Pm-=K7N5xT+aY2g;D#nhCUpMw80{4YXeaR0R=@t zPLukmp;Q4~j4nGqz+RmaAQrIv5a^noCcRxy_#VbhMkbiFQGfmfq%bNUyvoR+%O#{*w19z3Jzq5ns3VJXlW5Ft=%Dl zkq$*JmoT{U12$pC&r&p4 zkoo)fP5u$$(f<^rbm*&?p^D#5&wo@e%+E7fyv)wN+dEv%_3?aXLJ!GbB=+FW<{^K6 zMcMI$J?#PD6Ir@|GDNaIDR;*%ZYBivl)#9=AH;ItfNr!>Vlae7L{!>1VB6QrQuNv{ zBie*0tZGM?AGE!}`U?sQf^oWfKOrOIYlh$hCJTuCL8rKHw(DqRW##G`|D|UajBBvZ z*6_qq35+r51e!wrsPfFB5|T1S`~Uk0`L6tYRCPBchwq~~-0teSbcB#Tu+qA2V{1!H zPVQ)L|G~cQzyu4pDR{k*`Bk*B`35S8o725(fU5Y;x?ulN-XKkxGFfScNKl>8SK`J?CthpBqmPI3H)oUS3_q81jwYpnRHxd3f48BIk)q) z>2C}fM*?L3X(eyJPu7d7YbMZ~R zE*th^yA2R?0N=2ek9a?rmXkdIgK(gO@*HQxasZAaJX+X$-fWUOr;kE~B33V}f;Mbv2~aNclN` zj28eQ4Tk^&Jit>1nYz%7OG8Vmze7|J2PzXRML;L~;4l_-gJuHe^v1@+T z`d*E~Z8*9)7@+2ch9|(JLB5stKd-N-&=~T7`1hEW^rgb{B8EH}NN2;aSLuu&v8e$w zpN!jr@0Yn0RC`^Mwmr77*U6{R`q*r( zV%t)DimV(5*&P9aqt?BS-T{I1ya_6}+yeakk3T%8AgZ?GA)l}tDqynZx_*_2QP^N` z3cv%s9)wdJQ{6BdZ^!>ApvUk#VEM-wqyIVRr-euhQ=>h&v;p?aEh})FAAIJBP6M?~ zLxaJZk+A?)sVKx{o5C=4<%Zc`Xv5}H9i4G-E1)Yt00t@oHVANdi4B1e%1;!#8qbZ{NMs($cyu{e&N< zBm=oZ#JgLBydvV_x0#u5WQC@(>uG`qm_A=eX9Ero;AW6$!wj~I$Vf@LK>A(Z*N*he z^WIy$+9zgMZSZ29GDJJ0v$Cm41cJ5e6D?ks%$NXdD=4lJW)%WGWP-E#T55V;>&^mXMci z(qu=EeGf|$kgS@9M&GX^*f1KEP+}keFL!iuO6Rpzl9vyC^Tz(}-FSi05zxYm*mmwd z1_xqnMtBEj8|o1@oPk^G2U!YZehi)Pi6aD7$mYlF{?(teb;=pl=$M#EmXFn%VP_tg zwQbea+{eY1bc7(N)HO7a$`Ue{rBn#OH_QxWiNAw}fCRz*z`DXhg>Sz>Z(|f=&effS zPyoQ-)#?fLL<*Ymj@w@BP34bl#XhM~5-0ph$s25~F?(gkTsc@C81%Z#yx(C%Ilk zf+P<>NI(Wljv8P2)2$`2HXvM8-sljU;`8+BO0A3;NU#8r5hQaM9}lm0k?xH3$0S52 zBDmZGKth5}rfY8qLU#wQ@3C6+6~vFkI`)F&^YIZ8JG~M%OI5PJmW z;n+@Z>+2=FcE}}9u*}TO$KPQpfy$Ziw-@A{X4uWSb6q!l;uJOd8&tXUdCdH-8$#= zgt#Geu;IVeV;vWJ!^MSO259y7?t*xF z$DkFD5y#8?OZ8jS)Btcp7bxt29l;Gn5Hz$m5(K8su_O>Cl8P`d!YBg91Dkw5wh1CZ!}fbKHQ+#^T};9jx?#99 z1*tzF0|etdGBOx94EGpe1zYXc$%*oyHl&;(RTY{vQ4R+SY@({NlKE#mIBig0FkreC z^;}(DVL_dTMR8-7wt*{#AfT3-b@+*j(Ykqf#6(Bu+4aWuuR@X(zP%=U5%rUj=HvSXDJaJa*nVCnyu0^& z8xKJsLlp@IKA;X%1OTqk!>oskdD9Fj{%JL_HC@oCJ2D%=F)vC2m|pGzP<$74@{v? zgM;N|WnuU)U?xDx!OWJqbEn+8Hv(9r>QFp87Hn+=En?ZD89~g=7l7kVcH6$@LX{DH zcI4>iCm~itCFZ#aorU@31yc@8^^wW@yyC7R9$<}tIh=n20fF9E^m@^`X>iqH%hr6` zQ~vE;aNtSF$weM5N{C?%f?x1xuJI>a`^|m`c);c)bxX?}lX8_%FX){R$52*OeBv@0 zPr=W^$2T!E^Gdg%1imE23OdCtSrYilBrvK|60Qw|#ly?NS_BFV)CTf^hz!K{gk0yN zY|CWc7uVGAN=f|%F;ZT*Y}Uog(UH{xY!&PbTm-aXAr6iT#?f8ql~-3ivStrjGKlfx zXIzLCL&qrw5;S@#L` zzJ@ys9c;H)gXmBT4e3`1MPM~xdS{nQYEx~41Ws@6=;I5^e|^e0_Ao&w z&8b6_igjMDcCd>dCnp;VVzwzXw0P)}eI>l0TJ_meJ?zcM>La#eH3zysFccrgX5`Nj7OifMA%$yD! z%bPV-feTocS__(b{Bad?l*s!*z+j>Ww*B}NTKBOW13W6k*Bw7elGYJ`2Pd9K#anuD;5 zLL4aJKp0DU`sN?AnyTBLdox;nnv5GKsuW)@I-8%rrl`a1w{>uS#vA5B@b~p;7s^&m zzLh~JS^Q*K$jj27OT4)kVk!hDL2Pa4f>~V*hBasn{K4i6AxXnkVS!JYXATM literal 8289 zcmdUTbyQT{*Z-Xvn33*I0YMO?LAqrIq?B$&K|o>XkeDH)8>Kr9LIgpn@u3kxMoMz% z5($Z+f8+Cf|M;!-{jT?Y*LweYZ>)3oKKJgs&u7Qk=Kuf*fCALi0HNgu-rLQ~E7Rzew8 zTnC~?-@RK9Gg%sKD$~tF1LheHC$UxA0f%K6-sAVHW!we3lyG0SjARg55qYcY%dsE+ z$al8osKDKk^KwK-BZv<2wkrq?Yq@~3-%kYFHPm3pJNRL|)+KRX)1$c4TcqEJ3=U>i z{Xe_Kb%-mARf#?!Ixa118gX7aETa3oRU486YKoawW2j|abb=OEj(JOYC{+~fY8#{tX8)V`wzfz6S}$o2F@!m zaGyZ@8*rf1KfswsZjI~dv#C)NyQ3NHJh_dFnj+P6r4wo#~b&Ihyf>d`UJo5W($R5_*KI0J4KJsN-H z0LcJJCy~(m2L6}eW4~C;kgBb7vZ^^`@LufGE+4s14e}JX>@qwU?%li{d=NKG#nKgh zy4)no8^h>3V3;^x;wd?taik!R=lp>e(-$924eoZ73YBL!5&2Qa6R=2h5ql(ISw|C7 z&Ex%ErpzohZ~T+HDMKG!ej;Mgv&nJ6XfPnNowAtrLemUvp) z@VURPkn9SdaL^mKL1VZysWyr-0ZV#HTk1QOw2A|%nOU+^dBNYGW)FyFb?7_JowjGq ztPi#)!}<8LEhA2?FpA%RaOrm20S4a83R*#K>V%a&1NbP$PiUOh(d)>clQAvn9dNSgJ>{2zQygi(*3ESaVZMi{e(|gTQ}bNrm+%wK7gWO4aaE| zssjlohKH|8#e%aKPQpm>Li3vzs8s!xh|xN{=dgmYu6De6Qy_Gnw_*YGcD|4I{~+UO^5p16b!+7DdoleX$n ztg$Rdt@i$!10nC_6o%6X{|Ify+!%SV2CHoh}GA(HJtJQ)z z9ocdm+p~WC8WdJ-Ntv8zs`ip>t1$*F6TFoh1sq!BBMg2&5G83x#iM`5;;oz>=yQ zO(eOJ)9)E*Jd>sqy@eOyvt{f zLI)?{tUT|iSMr^yFRr5Kk5zl<5+>^OiXs%p@qrI@AJR&HD60wn@zRjAzqe!lO63H# z1vQa;CHYp4;1bnZ$XK^-uh(gX1zTgU zEJYC!3=Dvh^LMpxD;)p6S2;dts0YGtlYn-fVq~uZZ4`KZB1|J_E&{;I;Bu&eIcSgr zDN1Vf=p*Izt6z`H=B}XR0GRrD?Xdm@Jik;KSOMT~l(o#sw<`EYT+OQQfF>9>Hu+^a zE6EP-azP(F4-GYv`tEB(;v;;mdcI4T!pbqmKS@D`wSbBBQcv7djUk{1pHBgr8Jm#5 zXzW(*#BaDI=uOZD3P;licQKJ_hdFQ!hci;wO_ zB+xQsM4QnhUKeOb@lj6&8JUny+JV-`{&o6mQbI1gW5=oRve5sAP`WIWHumsT)FY)@9JUqsJATB^p z?re+mA^*A8*4&I-x>1saw8h;t7P)i}&^ig_%1$F$3B{T~v#bL({)#_+Q(Kcu{Fl4- z3dK|0KSmo1XopA`Q)VJ+IK+`G>aV=eJ=A&cE1xmdEm#OyqPcp@=eKza=6T(-QB|t2 z3txO*{P5w94zAR7~#_EPt z)3XPsF!Xw)s|~v(DrxtbT=6dkzeU@RN9dIWlbgBx_FTVw13&n`5ay)C) zm+fJJ&#JE!J111!Hgb_-;j0_U=*^5%F2Io#fP-bts?C%yS?Y#{Dul~PRKT@!{f6{O`jFj%WpWZ^B3_v@cZ%nw!&tm(CWJ3hPIcjb+2C)X zp8k%7t$x@c=#d{PF<$%#B{O8#oP-MvVpnZ`R+d7A9?bsB*M?yoc+|AT%UJ(`bOCUB zF(i6OP^|s~_JbDx1c>I?P;C%=a|v~Y%Q`VSX1%<|e7McrR=X2V4hqL?_s>1qxh~4! zW~4k9Qy)p!hA!moUv>X2EK2NVq8utk0JAr3LM_EYf(j4I)8jf4{MHD=s~?}yFLA1t zC|jZX`sW@`ao@A1jC1RujH}j`JkjMhm_L5BJOv@=Jz91#C!b!laO3qOU zf6tjWZc1Y?o-aPq+hoh^aqr3_?Yp1#j%Ti>C!}ZmaiVCjYx6g=LavYQ+&-p@zRn}R zucmWQjJq!CCm%L=NFtjJ@a#xuAh>7AWS`*`kKcNEpvO{0(~qCaz)9kl7w`vRx{-%; z!~UiK15RoB$*69rSx?wB@!H*2LP#K(x-?!2@-ObTv->> z+H2NBQrOI|1kbnTl;Rz3{_!UTIm@L>6HO-&_3I4D?30uVexcr}jVp3k@tx+46zl_P zyN0`m-zWa$f1Yr?qAy?|2~CU{kC63!;>jF*+t4{H{_SH5y4fA^UaRyS1r;(Ga1Y5G zP_)E+jdXjZiykM3Q=Dfjy}nMIe|5MqL}~@}?D0>;1FBl4%z}hI)-*Y|ts0~FzWEYg zY6p`ItJ#1tssX;&SrFl_S(U5K@8=qQYf8|v>$aiV2R(OS62*WS?R?*{22L$M=iOnb zv)L5rq$w^JrZh|v#PB?<7~anwxc%TDK!l6x>Y~4_M9QX`sgOdl7WaRccEsZ0c&476 zD5c3?MMD4eL`vAqAnZ*1J&{^_R{q)R__yUV)vbR`q%#(8ZAJqyyO#&8oEYY$N40vj zXibt4>IOk5Z7zS&gDc$9B0Bk4NATo`PFbZ|QI%#+y7 zqX=n9eFk$K+h5x7BSU{dk7BN2gzT?z9_@Og?%OXuRwO+G@FsnEE|PTb8ucNw#MTsV zL9;{y*FR~0^`rNv^=EEk@~57#@=huh9|T_zHF4d7pWJZl>;l;rz$co_P)-aKa;)}c73O`J zU#X)m;!Rt6h^=CZKM9_8M;p_HvB&H_eyxTTKX$-e{e z_Bk=G+xGkRCtbc{E_&oebTaIT{aTL=pShZs7(%o9?mN zuVls-)-;n=dflG5oBgX=QBqdl1QbCRiF@JA3{A>S1=*k2&`;Bc(hRix>Sf$3&cZk^ z(X#k#`>VBybsr_d$Px|*sCVf4DOT=d^HyaBvfp+A&L59b3tU5Ey_3|x@00XXEQqZu zL8QNzrML3oy@Qyhw%GfFV^2MDSAIZs1!W!fsgd^y+|4;bNn}#8t)-EIaN)G)&XLoa z-3=SPEYKTG0@`U(X%cqpHc?)mrk+F_f+=SBAp4S~gIPtxe3DY3Ehw5Xge)i9I-9i&yrd-m@ zS6Kv(*=v;QWVxzRf-;uZGEcDXDSma|7+aW->f5~TTDCbARvQjn0`7w|=@nLA>(N0F z4_W&vgme5CV}TAcMQFMqiv$ZOoV4UVRba%)2Of=_Z%>t{K~*3(X}rlwBXA~ zMR%q_o80B5!~x>#Z>_y0+zmWU&UjO#A}mA{=~k}%D&S>mNUogcu140$$dx(%A+^hg z)s(N^xd|BDtR5OI3h`q^M&KcB5WBtMzOPqBb~*EswAaFiejId+ySVWT@x+Ew{2Z#~ zke&iz{RE0Bz}tPbd2N0wyQ97egB@d?U!nN|%A(s}36i8Uj6F)I_6wFbk}Y|AT2bf5 z7toD_POhcaTkG0sjnVrgM{8#-H_f6x2?kT)vdO2K$nYl5w<>LHp>6s1734x7!LD?> z3}ZF>F0ZTklvTKf#&VS3qwq<=Pd+n+NcliY4|sUIyd$LOK18Qx0A>#-F3b=xi|km+ z-3*W)_)>sp$xr#k5MR@#pAq2Q{U;H{d3ZF7l zD)w@QBGqBT<`~?#!yG(sSV*Dap6;QtkZ29lI+tL1f83O zOaMmW#&+JO;8CY%$kX9a`ev6+7R(^-(4=53_w{Q)Q*`j-4MMMgd_}CVxfOv?Gt?;X z=XH1Fsn*!rC$6Ad^aO!kVom2i{;1*dE}k&neDo$z*$9me{`gh{t^Wwg5_F|1aGH?DBAjurLCg#a==uvco2?z{p!o=UIR7ejhfaLO&g1Mi{2I zQwdD>Ad6%A;xhqW7gtaw8vz}+-QrL> zn}!BJn99xWKu)0PJntWyYWn$V5k48}#~r>A0z?Ux4EaYwEA!t!#C*UVnh`hFoM^6& zApf!Pa9-;bX7btR8{$`51pV2p!l$-hS2+O{sj;XLvJAj0tkGzc1#_=+C^*EGAggC8 zY>Eq%PcnO(pg-nDr&)_MVN9H6(8hL_38t2WYMnO97V)O@H#^Fxa(8NfryZH%P_jiK z3m}Ac`hpfG^2fVvD1}L9yUTHhJtlR4V)lJDLI6`VSS7MgCf{cZc2Y)0cuR^2eVAY1 z&i(|tOR2T8MuT-P(qW5HYT^iDEqrCA5fq~QvP#w_-Ii{rndQUoJo%KN!g6n3l;o2V z_Ziio$Qd%>fzhiC-8|TmkAwi1F^f7Gw#EUwu;UNb9CuyMh<5`%7D%}^xNOF#d~3(} zfTcaWP^&RRU!HmTJXS2*1_5us)FwQ_z$Bk&Zy_a-rq8#6cspcHX0tj~D1QP9sC$2s zn{mc4posqga)#+i@^nF|DlK5r+laKRmHblc8OSMLP7q<`s^1*VXm z$+iv56Z&mu%~A%9*-nsS=)7By5nK>9mh!K>yl=Zlp$Wr(SDd-abxIWA_3LQQ`%M-U z@r8GpHD05Xn9I0gw6tb#rN8lOlg=n2IK71-fUQp&9qC58VBKT>Co*y-bgD3+5kR!MY4p9*}A z=()tc!6WjN?puDg=j6X#upv=rkkPAO>G#Wu4znnN@+9O;4vM+H?@ITRPe8Ft)D0c# z{keyJsLTFu?PHeuC$WYx=&5J}*rdJOI=_1iJE6=GZ;RVH^ynwIjoOb!`UEjCEP3v! zY_PF~GR$=yvVl1(rnxzxzY0}M4X~Z$02l8zU%}$yYGCMcGxHLORzp<&IX1+JwPN zkX>1jtvCG3l-!(3uLMr3pUN|8-+RF~SJDvss`TL0tBT*J4u1{l`#7mfI=!7sOy&Y+ zuABY1SaBuUZExAUIkZ?@J-elx{Nm*vBqT`DK%RH!_WfL~Qio{+l{1IkSD28^TN+DN zIze^ssG8FDm;PdiDtAZiznE3%`(4%csxz?V*N~g?qgB`)z7L>KgFwEAVRxpvcVuu} za06SNq{Wzpo>G%46U>h#8@0#syAnvI^^ngw@3`Zo;qv^hi0#mpJIA}(a7EpdIunP6 zdHMq|?o?`9QWu6QNL_j5&M(5e%U$B!l-BUy zj$d0<<=;P!I#S2UvDAF`+IsnV@J1_dc0>{4{H>jRqf#opu+*s^R>cKPPcb z-U9$mZjqMS=KaLVhC@I$HD9$s-RdT&+md)X?w-%6m1y!T#JS7v!eP=J;GVJnMCBh$ zD3))KL(CvU>9Z-eu7qOOyrZ;_H;GW$O3(6Rb0fyi@vdS87kT23_e0-SET;ucDzhwe z@3@zKw$^wi8ZctTfh$0%0EbMoOb5LR>q8mv_={L3gnRDm$U$zA@7D5t@fYz?JL^W9 z6sa$t$g3YmNS^Q}f(weyGdfuvD4dtauzgPH2?8FVA(yX2ZY4kWhHq!x(mZ*yt)su7 zj_oYMBAl0Ep6$dbZ7c-h1PMzD8akDZM>9UOlBgWGWV?>Q+u@uE=yZI++8Eye*sVjP`v*-P!`UDUVhk zp`EGH48$y@b;Nhw3fG8z{fmxLt_uzPbK4TSl_8Gv#@~-G@_zD`=9DKhjs~$zkhEw9 p9{6VN>qXqhPR9iw`x*U@m!XJM{`E`X>&nL=UjtpG|9=Vo{{Zl!HfaC= diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index 7fbc3a19f5281cdc3ea00a3e3ad11bc6bf6f8841..64b6ed4f5b10ff05ba42a32d946c26ab48009fe0 100644 GIT binary patch literal 22832 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 BSzG`B literal 9291 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 diff --git a/desktop/src-tauri/icons/icon.svg b/desktop/src-tauri/icons/icon.svg index 4c1774afa89..7070d673ad5 100644 --- a/desktop/src-tauri/icons/icon.svg +++ b/desktop/src-tauri/icons/icon.svg @@ -1,16 +1,43 @@ - + - + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/icons/tray/icon.png b/desktop/src-tauri/icons/tray/icon.png index f475a9e9c73a6d8b5b52d1d0610531bcdf987cbf..1eec4cccaf1c6489867b55729ba4098626511a6e 100644 GIT binary patch delta 877 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 JPDHLkV1hRlXfXf) diff --git a/desktop/src-tauri/icons/tray/icon.svg b/desktop/src-tauri/icons/tray/icon.svg new file mode 100644 index 00000000000..1f917f1f018 --- /dev/null +++ b/desktop/src-tauri/icons/tray/icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/devlog/_plan/260921_brand_icon_and_menu_bar_mark/000_plan.md b/devlog/_plan/260921_brand_icon_and_menu_bar_mark/000_plan.md new file mode 100644 index 00000000000..20a374ad5c8 --- /dev/null +++ b/devlog/_plan/260921_brand_icon_and_menu_bar_mark/000_plan.md @@ -0,0 +1,95 @@ +# Brand icon and menu bar mark + +The shipped app icon is not the product's mark. `desktop/src-tauri/icons/*` derives from an empty +rounded-square ring that came in with the Tauri template, and the vector source written for it in +#5329 reproduced that ring faithfully — the measurement was right and the subject was wrong. + +Two consequences, both visible on a Mac today. The artwork covers 21.4% of the 1024px canvas and +has transparent corners, so macOS 26/27 classifies it as a uniquely shaped icon, strips it onto a +default grey tile and scales it down; Finder shows a grey square with a small black ring in it. +And the menu bar carries the same ring, so nothing on screen says which product this is. + +The real mark already exists in the repository. `assets/logo-light.png` is the mark on +transparency at 512px and `gui/public/favicon.png` is its app-icon composition at 128px: a light +squircle behind a dark six-lobed cloud that holds a `>` and a `_`, flanked by `{` and `}`, inside a +dashed orbit with a dot at top and bottom. Neither has a vector source, and `gui/src/icons.tsx` +holds only 24x24 line icons, so there is nothing to reuse — the vector has to be produced. + +## Where the geometry comes from + +The silhouette is measured, not redrawn. `assets/logo-light.png` is pure black with a shaped alpha +channel, so the outline is the alpha channel: upsample it 4x to 2048px, threshold at alpha 110, +trace with potrace, and map the result back into the 512-unit source space. That yields exactly +nine subpaths — cloud, two braces, four orbit arcs, two dots — and re-rendering them at 512px +disagrees with the thresholded source in **188 of 262144 pixels (0.072%)**, which is antialiasing +rather than a different shape. + +The prompt glyphs cannot be traced. In the source they are engraved: alpha 217+ against a 206 body, +with a lit rim along one edge. Composited at 512px that reads as depth; at 128px and below it reads +as nothing, and an app icon spends most of its life at 32px. Thresholding the emboss produces a +ragged chevron because the lit edge falls below the threshold asymmetrically. + +They are redrawn as flat geometry on the measured centreline instead: + +| glyph | measurement (512 source space) | drawn as | +| --- | --- | --- | +| `>` | rows 214-238 give the upper arm centreline slope 0.5625; rows 254-278 give the lower arm slope -0.5833; the two meet at (214.6, 244); tips at y 196.5 and 292.5 | polyline `193.4 206.5 -> 214.6 244 -> 193.4 281.5`, stroke 22, round cap and join | +| `_` | x 253-324.5, y 271-293.5, ends semicircular | rect 71 x 22.5, rx 11.25 at (253.5, 271) | + +Checked against the source: the chevron's predicted horizontal cross-section is 25.3px against 25px +measured, and the underscore's cap curvature lands within one pixel at both ends. + +## Shape of the change + +The glyphs are a **mask** rather than a lighter fill. Cutting them out of the mark makes the +backdrop show through, which is the flat reading of an engraved groove, and it is also what gives +the menu bar template real holes instead of a black blob. + +The backdrop is a **full-bleed opaque square**, not a pre-rounded tile. Apple's current app icon +guidance asks for a square, unmasked, full-bleed 1024px source and applies the rounded-rectangle +mask and material itself; a baked corner fights that and shows as jagged edges. The 824px inner +tile with a transparent margin is the pre-Tahoe recipe, and the transparent margin is precisely +what triggers today's grey fallback. + +Files: + +- `desktop/src-tauri/icons/icon.svg` — replaced. Full-bleed `#fcfcfc` backdrop, mark in `#2c2c2c`, + glyphs cut by `mask#prompt`, mark placed by `translate(2 26) scale(2)` so the orbit centre sits on + the canvas centre and the ink keeps the 77% coverage the favicon composition uses. +- `desktop/src-tauri/icons/tray/icon.svg` — new. Same curves, no backdrop, black fill, orbit and + dots dropped because at 22pt a dashed circle resolves into grey specks. viewBox is the ink bounds + of what is left plus 6%, so the glyph fills the menu bar height rather than the source margin. +- `desktop/scripts/generate-icons.ts` — `render()` takes a source, and the run emits + `tray/icon.png` at 44px (22pt at @2x) alongside the existing seventeen. Both `icons` and + `icons:check` cover it. +- `tests/ci-workflows/build-desktop-icon-set.test.ts` — two additions. The tray raster has to be the + size the generator declares and the generator has to actually render and report it, and the tray + source has to carry the app icon's mask verbatim, wire it onto the mark, and draw distinct curves + that all appear in `icon.svg`. + + Subset alone was too weak, and a review caught it: every interesting way of breaking the tray + removes something, so a strict subset stays a subset. Dropping the mask, deleting the underscore + or repeating a brace in place of the cloud each ship a black blob with green CI. Each of those, + plus removing the generator's tray render and removing its `produced.push`, was applied and run: + all five turn the suite red at 6 pass / 1 fail, and the restored tree is 7 pass / 0 fail. + +Nothing in `desktop/src-tauri/src/tray.rs` changes: it already builds the tray with +`.icon_as_template(true)`, and the asset it includes is the file being replaced. + +## Acceptance + +1. `bun run icons:check` reports every generated artifact matching the source, tray included. +2. `tests/ci-workflows/build-desktop-icon-set.test.ts` passes, and its drift guard fails when the + tray source is perturbed. +3. `icon.png` is fully opaque, and `tray/icon.png` is 44x44 with no non-black opaque pixel. +4. The change lands on `dev`, and a locally built and installed app shows the mark in Finder, the + Dock and the menu bar. + +## Recorded results + +- silhouette trace vs source alpha: 188 / 262144 px (0.072%). +- `icon.png` opaque coverage: 21.4% before, 100.0% after. +- `tray/icon.png`: 44x44, 759 pixels with alpha above zero — 498 fully opaque and 261 antialiased + — and no pixel with alpha whose colour is anything but black, which is what a template image has + to be. Both prompt glyphs are transparent holes rather than white fill. +- `bun run icons` regenerated 18 artifacts; `bun run icons:check` reported 18 matching. diff --git a/tests/ci-workflows/build-desktop-icon-set.test.ts b/tests/ci-workflows/build-desktop-icon-set.test.ts index 703356188bb..afb1d83d5ab 100644 --- a/tests/ci-workflows/build-desktop-icon-set.test.ts +++ b/tests/ci-workflows/build-desktop-icon-set.test.ts @@ -164,4 +164,72 @@ describe("desktop icon set", () => { .sort(); expect(stray).toEqual([]); }); + + test("the menu bar image is the size the generator declares", () => { + const source = generatorSource(); + const output = /const TRAY_OUTPUT = "([^"]+)";/.exec(source)?.[1]; + const size = Number(/const TRAY_SIZE = (\d+);/.exec(source)?.[1]); + expect(output, "TRAY_OUTPUT is missing from generate-icons.ts").toBeTruthy(); + expect(Number.isFinite(size) && size > 0).toBe(true); + + // Declaring the constants is not the same as rendering them. Without these two the tray could + // be dropped from the run and from `--check` while every assertion below still read the stale + // committed file and passed. + expect(source, "generate-icons.ts declares TRAY_OUTPUT but never renders it") + .toContain("render(TRAY_SIZE, join(target, TRAY_OUTPUT), traySource);"); + expect(source, "the tray output is rendered but never reported, so --check skips it") + .toContain("produced.push(TRAY_OUTPUT);"); + + const bytes = readFileSync(join(ICONS_DIR, output!)); + expect(isPng(bytes)).toBe(true); + expect(pngDimensions(bytes)).toEqual({ width: size, height: size }); + }); + + /** + * The menu bar asset has its own SVG because a template image carries no backdrop, not because + * it is a second drawing. Two files holding the same curves is exactly the drift the generator + * was written to remove, so the curves in the smaller one have to be characters out of the + * larger one — a redrawn brace would stop matching here rather than ship as a second mark. + * + * Subset alone is too weak to hold the asset up, because every way of breaking it removes + * something. Dropping the mask, deleting the underscore, or repeating one brace in place of the + * cloud all produce a strict subset, and all three ship a black blob into the menu bar. So the + * glyph geometry is compared whole, the mask has to be wired onto the mark, and the mark paths + * have to be distinct from each other. + */ + test("the menu bar source is a subset of the app icon source", () => { + const icon = readFileSync(join(ICONS_DIR, "icon.svg"), "utf8"); + const tray = readFileSync(join(ICONS_DIR, "tray", "icon.svg"), "utf8"); + + // The glyphs are the whole point of the mark, so they are compared as a block rather than as + // a bag of paths: an identical mask carries the chevron, the underscore and the white ground + // that turns them into holes. + const iconMask = block(icon, '"); + const trayMask = block(tray, '"); + expect(trayMask, "the tray mask is not the app icon's mask").toBe(iconMask); + expect(trayMask).toContain('stroke-width="22"'); + expect(trayMask).toContain(""); + const trayMark = block(tray, '"); + for (const [name, mark] of [["icon.svg", iconMark], ["tray/icon.svg", trayMark]] as const) { + expect(mark, `${name} draws the mark without the prompt cut`).toContain('mask="url(#prompt)"'); + } + + const trayPaths = [...trayMark.matchAll(/ d="([^"]+)"/g)].map(m => m[1]!); + const iconPaths = [...iconMark.matchAll(/ d="([^"]+)"/g)].map(m => m[1]!); + expect(new Set(trayPaths).size, "tray/icon.svg repeats a curve instead of drawing the mark") + .toBe(trayPaths.length); + expect(trayPaths.length, "the menu bar mark is the cloud and both braces").toBeGreaterThanOrEqual(3); + expect(iconPaths.length).toBeGreaterThan(trayPaths.length); + const foreign = trayPaths.filter(d => !iconPaths.includes(d)); + expect(foreign, "tray/icon.svg draws curves icon.svg does not have").toEqual([]); + + // A status item is tinted from its alpha, so anything but black-and-clear is a mistake, and a + // backdrop would paint the whole menu bar slot. + expect(tray).not.toContain('id="backdrop"'); + const fills = new Set([...tray.matchAll(/fill="([^"]+)"/g)].map(m => m[1]!)); + expect([...fills].sort()).toEqual(["#000000", "#ffffff", "none"]); + }); }); From 5794348b0d02356ccf919118eac0f53bfef9292a Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:35:53 +0900 Subject: [PATCH 43/87] fix(desktop): keep the alpha channel every generated icon needs (#5356) The new backdrop is opaque, and librsvg drops the alpha channel when nothing in a render is transparent. The result is a valid PNG and a broken icon: tauri::generate_context! refuses a window icon that is not RGBA, so the desktop app stops compiling with "icon .../icon.png is not RGBA". Fifteen of the sixteen committed rasters came out as truecolour; only the tray asset, which has real transparency, kept its alpha. Nothing in the repository could see this. The icon tests read dimensions and container structure, and no test or hosted job builds the Tauri bundle, so the first thing that notices is a release build or someone building locally. The generator now re-encodes a truecolour render as truecolour-with-alpha before writing it, which also stops the committed bytes depending on whether the local librsvg felt like optimising the channel away. The regression test asserts the colour type of every committed raster rather than trusting the renderer. --- desktop/scripts/generate-icons.ts | 112 ++++++++++++++++++ desktop/src-tauri/icons/128x128.png | Bin 4985 -> 3512 bytes desktop/src-tauri/icons/128x128@2x.png | Bin 10519 -> 7571 bytes desktop/src-tauri/icons/32x32.png | Bin 1034 -> 845 bytes desktop/src-tauri/icons/64x64.png | Bin 2357 -> 1814 bytes desktop/src-tauri/icons/Square107x107Logo.png | Bin 4091 -> 2907 bytes desktop/src-tauri/icons/Square142x142Logo.png | Bin 5556 -> 3930 bytes desktop/src-tauri/icons/Square150x150Logo.png | Bin 5913 -> 4170 bytes desktop/src-tauri/icons/Square284x284Logo.png | Bin 11897 -> 8614 bytes desktop/src-tauri/icons/Square30x30Logo.png | Bin 955 -> 806 bytes desktop/src-tauri/icons/Square310x310Logo.png | Bin 13130 -> 9581 bytes desktop/src-tauri/icons/Square44x44Logo.png | Bin 1524 -> 1173 bytes desktop/src-tauri/icons/Square71x71Logo.png | Bin 2618 -> 1923 bytes desktop/src-tauri/icons/Square89x89Logo.png | Bin 3389 -> 2414 bytes desktop/src-tauri/icons/StoreLogo.png | Bin 1775 -> 1377 bytes desktop/src-tauri/icons/icon.icns | Bin 142884 -> 157883 bytes desktop/src-tauri/icons/icon.ico | Bin 21175 -> 15562 bytes desktop/src-tauri/icons/icon.png | Bin 22832 -> 17567 bytes .../build-desktop-icon-set.test.ts | 16 +++ 19 files changed, 128 insertions(+) diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts index f16e8179a4b..04ab6e7c803 100644 --- a/desktop/scripts/generate-icons.ts +++ b/desktop/scripts/generate-icons.ts @@ -17,6 +17,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { deflateSync, inflateSync } from "node:zlib"; const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); const iconsDir = join(desktopDir, "src-tauri", "icons"); @@ -76,6 +77,117 @@ function render(size: number, out: string, from: string = source): void { const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); } + writeFileSync(out, toRgba(readFileSync(out))); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(bytes: Buffer): number { + let c = -1; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8); + return (c ^ -1) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, "latin1"); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** Undo one PNG scanline filter in place, given the already reconstructed row above. */ +function unfilter(kind: number, row: Buffer, prior: Buffer, stride: number): void { + for (let i = 0; i < row.length; i += 1) { + const a = i >= stride ? row[i - stride]! : 0; + const b = prior[i]!; + const c = i >= stride ? prior[i - stride]! : 0; + let add = 0; + if (kind === 1) add = a; + else if (kind === 2) add = b; + else if (kind === 3) add = (a + b) >> 1; + else if (kind === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } else if (kind !== 0) throw new Error(`unknown PNG filter ${kind}`); + row[i] = (row[i]! + add) & 0xff; + } +} + +/** + * Re-encode an 8-bit truecolour PNG as truecolour-with-alpha, and leave one that already has + * alpha untouched. + * + * This exists because the icon has an opaque backdrop. librsvg notices that nothing in the render + * is transparent and drops the alpha channel, which is a valid PNG and a broken icon: Tauri's + * `generate_context!` rejects a window icon that is not RGBA, so `bun run build` fails at + * compile time with "icon ... is not RGBA". Leaving that to the renderer would also make the + * committed bytes depend on which librsvg drew them. + */ +function toRgba(bytes: Buffer): Buffer { + if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("rsvg-convert did not emit a PNG"); + + let header: { width: number; height: number } | null = null; + let colourType = -1; + const idat: Buffer[] = []; + for (let at = 8; at + 8 <= bytes.length; ) { + const length = bytes.readUInt32BE(at); + const type = bytes.subarray(at + 4, at + 8).toString("latin1"); + const data = bytes.subarray(at + 8, at + 8 + length); + if (type === "IHDR") { + colourType = data[9]!; + if (data[8] !== 8 || data[12] !== 0) throw new Error("PNG is not 8-bit non-interlaced"); + header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4) }; + } else if (type === "IDAT") idat.push(Buffer.from(data)); + else if (type === "IEND") break; + at += 12 + length; + } + if (colourType === 6) return bytes; + if (colourType !== 2 || header === null) throw new Error(`unexpected PNG colour type ${colourType}`); + + const { width, height } = header; + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 3; + const out = Buffer.alloc(height * (1 + width * 4)); + let prior = Buffer.alloc(stride); + for (let y = 0; y < height; y += 1) { + const start = y * (1 + stride); + const row = Buffer.from(raw.subarray(start + 1, start + 1 + stride)); + unfilter(raw[start]!, row, prior, 3); + const target = y * (1 + width * 4); + out[target] = 0; + for (let x = 0; x < width; x += 1) { + row.copy(out, target + 1 + x * 4, x * 3, x * 3 + 3); + out[target + 1 + x * 4 + 3] = 0xff; + } + prior = row; + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(out, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); } /** diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index b500b7b11925165b02b336b272197df3b74a63f9..8827b1fdc27bb4ee27a2863451dfd3504cfb4ddd 100644 GIT binary patch literal 3512 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 diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index d7616e4e2c0a880e10f6068fb38c237e47e18a9d..21c507c776cc8f51ee1cdd4dc9f560fab642eefb 100644 GIT binary patch literal 7571 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 literal 10519 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 diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index d378409ff52555c42c7da1ed91a6cb0cae9be9f7..47a19b8131c7eab93bdb6011ef0a00837e33e1c8 100644 GIT binary patch delta 833 zcmV-H1HSx<2+anN8Gi-<0047(dh`GQ0~AR_K~#9!?N>`nYEcyK2bq+CGY1JFje-m_ zM2G|{Dxq1AK|wkQ8j45*C5W(yB8pUk!oakLgj$4J5EVI4$*{CESeLcn9M194IhEbZ z1qX}$aPMz@XRoJCA0Hp^FQutL;J+sD{{9YcZ*PB>Kr9x6jem^|8V?Q*!0YwG&CLz0 zudl=9<>l`YKya7K1?T7IP%4$Gp1HWVfaT?78tY2H=kq}*6k^xM$H!o^*}!hM6QT0+ zfj|Hb4-e}@0Kx9=?r8q>^aKL~10az|crr0D0h^nfu)Vzvx3{-+7R|E1zyEy#nM|f) zubT35X>M+YxqrDia?qET7rK|tW}#RtiX)KE=b^W^7xa2P@6zt>uAp+RSglqb@$T;K zsvlKB0PTwxm~}dxhW`HkFDXZ9YilEuF*qiZsj5kusyW=)*ci>x+r>P4EeQsLEbi3Q z6zuHm2ot~@)m0V@6<=Rp zLu+d*t>tn#yuQBj?hg$O@dz+=0mE=_Z%>#&E|&wfT209a9ga%?vmB$G5tT{>Jv}`n zU0q$g-Yc!6Ij($n` z%E}6x#)Yclrv%X6M@L6gOpHdODCJtMmhBfX^e~zIs2rc1oUo3BV2g{3lpltNhbxq$ zo$v4O=`4;+2>!Ss!2*RfqI@6QF^uoo*;&dTO!-B^SUv07l3}C4^n$g{V1`7$;dW1T zZ#qh^uCC~5@hj0h9*+kikqGUfztn0xex)tP=7o9o#y_J5f$z#6b2W=`cyMu-00000 LNkvXXu0mjfLjjVw delta 1023 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%0DY9SB^h!2`0LW_<4OHWUa zii%Pw6j!cX(M&6Hi_|yvqeqXB@HaX-`ryHX#tB$hSm5z^Xv_8M*Ci5(zrTNOZmt0W zOiWC!UAso3(MCo_hK7bTc8q;SsZ=6C3?7f)^ah4OXJ@D8StgUUx3}Ba*i=?l>f0Xl zt`IyPkG#G=fBw`MLqkLL)sKmZadUG60CaVA_4oH1;W~m?EY>{F&d$Ox?B(T!vAJO_ zapVL*UsnSI0~rj4ZcR%|%ZnE;0stTgx_kGo5q@hx#}z7-3jBT$>4J5cGchp%zYDcN`Z^NJS5;MMWkQuorM^QHxu@t792~59MlQV8*47wK zVnt-%zkfe>?wne!W-^%!1|uUQ1KEB zEiJ95rw5~zahREz)z;P`In$FTPs-(Tb8~Yxn~j|>+B~RNtJPCeQx+B$WHK4Cl$Mr0 zefo5}{6;V}H7zMA2?+^7{!}Owa=F~m(NQZJ(ZztY%F@yjG6TK6z1V@aq1f5km6erY zWbZIA;QK&9LBZ0}5&(d~U|SJA0KaSZmzNLcs%skzbOU?U^1By1oidx0RSizN>x=A`p_`~XJ=>hrU?L; zpPzs8<_&VH{2_*YV#>+MsjjX@PO-y>4|BQPBS(%H)B~E_++3}I`_`>nY&IMF?w|!$ zVoMhSf#B=wTV7rs5D-8l63Jw8baeEgLx+e&qC%lqSy>^GNUpA~Yinzprb<~XmR2sh ztgI|JI2dEg-wx_&HBRnxay)Ku_IUG(zL_}Cv7)CX*SS*o9006eOwk|F%T6c=Mxw+!v;>gHI>`pM) z()R(iTK(X`gSol6%*;&9IsJz~5Y*V%`1bAFoSYmF4-cb#*oF@rV`F1AH8uA3_Hl7> z`c@R0_4W0}#>V>k`tb1Z*x1-jKg(jX4FV8^LScP41P+wnPP*9MgqhnH1l29n* z^Z8=2xS^rJ$H#|Gr*9?X*JYapXd#tKl}e?ntLxtz;_nLhAF%uXLOac_fSqPnz)rI( bV5j*PBT{Z&Sgr`w00000NkvXXu0mjfk1lTV diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index df082c8d71d829cd379b4267e767a212171ac35a..9436cf61e1c3d83c8c3ec46ca7c7e0be48ada2f5 100644 GIT binary patch literal 2907 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 literal 5556 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 PScHPCicGPTq5uB@CgrC$ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index 27fe4bca5b0a7bd68b910efc0ece7c08d8581818..5148c874dcc789f86c4dc35d3a7ef510588c871e 100644 GIT binary patch literal 4170 zcmZ`-XEYpKw029E8(0&3j9J5& z`5ynY;-{a{LhDDU#PHMGQAL6u+`h(>`d@=wO(dLNkGuO0WhD4tkP`bZ_&>gX!N1#o zK~>=S(e$&;wVcq@MtZxiT{Vsa6G#@D5;keK0?xqY+vyBEpdgH5py^YeKK=}IP8rxN zpD%AW0KL?kP=2&EU5;W9D0_8rR*Ktgg>cAu>7#0^^0@nR#(XD@#tzbPTX7GZ5ymypV*I1upi_fna9G~MQ$??@%TUebL7^Q+4X z_3e`4oL5c9yG?!@d6%baIYFTZgAYearYa%RJlq85-ES?E6;`zq9S8~}ShF+zq4Od2 z!Hj)Esnbx#Wb0uAj8Gx$!7|QgIj{?FNe;L%d1yd{qLrNab6#pJ8_BcKi+Z>5BJ}0hE^b%pXxvCy{wigc@X-iP zjOOvg*^ul4B^E$s7>s6@1+hH!OAOkspQc{pv|U*tM{z+n#tPLcbXi-BluipS^9Ub3 ztNnk@B%blVI-0a}o2_$o+xyX_zA#?Hn1?T`pq4*DT*!T+2hyd)lsc16+Z!Dk7$fNFmoa^Q-0*q;cNkmb*bgE{Hj7ge~OvXE7Y zgf@EN>+zhxvYqAwL)gsXs0_#2m-p&!L^rsae9?1u&WBF=t4e|q-@CaQ1Thiicm+&tBb9+zVr4F6Mtg^R;J$;j z!)JOp0rt^#sqHDw|7?L17_{CNxP+E{-C+C~>5dFG$WpL~acl~)g?!hPomUgqDJ%Gn zu$8{=|Bf%Y3t%XbwKe*d3JYjP)>tp6n5#Z{b-Lm-}@$IUX^`g2PxH#{#*r`4cjFHhhUU*HF@q{mC^+&g#K+FhWCTVn1&e@hHYc zOnWhgEs0^}M_2Sb|J0-1W;6dqN)f^*%hKvPN^WpA-}*4X(nJ5HtyQ*&d(Gfmo_OY$ z+y#O&mLtFujA4szzNwu5&v#SyzmPYQbmO9^81D1=h*dvnG%c5yIRn4%#4medc&8S7 z;BTKZLCFvS8fK9a zSsReyIPp9mnSg2%An0x!(ilEZUbGv@;OEu6+jjL$)b6^yzqVUad^p(f^F&E@2W&Gm z=wL{83;i+p#N%U(W@q6QsYG+qlZ1Ao7)H8r>yqW0#(|}6~?rp$My__;qr4SqP$KRc&p{n#miKt9< z7hVGzBkA;q6Fqr$TvTNaU}W$X*A<5&rCF?Z_MtbL;JK?lRgFH6b)haVf;JWioIVw-Fq!(eZ0_Bxjcn ztL;jyq=GT$mr|3tuMGI4n+{DwX*P=+r1NDiStNt`P;ZAB4_mqudz=64;6V3txZ&oG zc9{CAI|JQZ>l94z2>Xmwu@YA78+ar?>RxA@FK`wL5&xcIX@i<8YoW%A0d)H`bj=_l zP<;X%?*^UnrEX$X%;ff{Z}FIKvYHgXj?@1w>Hib#Y$@vJg7GtWnBEBzjeLv2!1`P& z=xL&#?lDpdW?%IQQj@7_{8N;r+s>2jPdKLat;0cs+aw@~W$*IOuE75Gr%30V zkSVYlLuy!r%0pRU=40K`Ck=eJvL`pg$L?(k7tp1ZcX!sml^RDW_I&V3|H;}AF(xge zM}chNZjB{2Y&=_+Fd+51%dq)oE9L`qf*&TxhjlcGuY-QA`Nq1w20dmk!#+*s9emSv z@-5i@sZmRD#eJ@PDS&r-QF;DYu9E9q0HaLrOc!I>K>ZpALhkolFzuqovhvmWf&McU z^BlIchhd+SWhVPZoscdl25hGUg0)ZUE*dvIL68w#q);%(pTIJW~EN?8aieOrW+-?x%HI7HKT8>9Z(~z6yyvIn)H&(o?6q%F1+-0 zzljo2aB!-`zk_8AIrcY0Q`lR57Axi=k7-;OflgXkrtD2s_-SFe&C>ppuFX z6QmlpLfqJB_^EC9-Fp#fD|Bo^k@@6~@VeK_!_miRe1}1zUmhx0Q{L79^FFzu4*1wc zwz@8+T$}P>+VKZ`BpK}2yszAjxTWMG6|eI=BhRxCpYNX9H=r);5ggyyFPp(#b)r}? z``Q;d?PBv!T_C^$m&M=;HWkQ|8-I`#XoJnmqh0}=eJ!DanAinwJ6%ZhiAln2JE=D; zKa5rK=x@v#%P^A$u*)zXFbI%Fh>C+6wY1oBsR-9^P}%S~Ry;YrC62phlG(?0VH2IpW+3M3+5 z_n$<*yK_QbV0$yi6<5zat#^;(?~dMz=Aai4h+nb!d>HCQiQ+xLqucN9&f_dcd4m>fEV-ji)fUUV8o}1`xrWkN%4c+vp;pr5Ua8>U zkeFw1BAFXotMT8qE~a^*A^j{iRwcQdGYM||b;Iljkp;2|r<9t*1kw@<`1Ld02jpalQK8;WFH;J8tD)ARfsTr`E`a%PRyFdn}VD zTij06z2}kh-jwPe9mugoC1PUB6xrJ{m&iVvHIPxsENZ=uH@`1^L?z-21lA(KlHvPF zs2s&W9}$xu$wFooYnv9G+8;zO}Qb7r{Kw@z#H8U;R#tjpjff#7aF_jk;jT(TaRR?hsV zY6?P`O1Je2PSAY~q@Sl$7tjavUW*z+-;Gvtqg44p3JuC;rbAheEE6Q3w})vs4kN+0 z`RPXQVSRe$nGM=Ov&7WW*K_U_dc(Ye%csSluU)B$a5cV>aW)Wp>H4`4R_Gk~JVg>s z<|7eq{`9 zhJ36PZju~eB4~Fy-qXDQxLE?4Qel*!1G0RTkOtR41Nv_^y5w4NV4Fpw#xovEP$wBH z-z&GPT2Y}y8OK|4U*A@WH!_p2p%uT!f6d07qgpL;(D1fNszZ6AZ(2}nPX+I=t zI0aHz6~`lgsK*1BNbo-qi)^({Lt}w2Ek{`AAuR4-r(M}ef)-e`HZdZip2g+f+ z|7z<0Up|Nbul~OFujKx#snf~-&mc~TUn+8Os`kEh<^kmI+}(WRcg;8{sW;J B0{Z{} literal 5913 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 aSgwvQBJWZ&%`(io5$ei1O79h{qW>S;m~|%r diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index 68d2404adda853565f5653b0e10481c8ed03da10..d4ccde8b23f1965bcd88e15f313126fd59724c61 100644 GIT binary patch literal 8614 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 O1o;mtGL_QC;Qs~W4!L3g literal 11897 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 diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 904b613b47019f7434d5c67f78e78f959bbb72ef..acbbb84ceb3f23d910797db2506a9c5cc07291e1 100644 GIT binary patch delta 794 zcmV+#1Lgd?2c`y)8Gi-<0028MuA%?{0_{meK~#9!?Uu_+YEcx%`-6&tULsT!PRxNI zVGo2RHR(Z^15I#JDNUk5Bn}!h3L0iq^hO8;4a2DDVU$vWB0ZJNf_3>8oWnh39ZwJL z9W+?5_df3Lw_R)PwRXwN%M1MrEBOWg_uzOuPG@IlKM%gWy?>>}#YOUZy>xVR#L(m8 zV>YZUEG*FV_4W6HeLf$pt*ucmmwWd&__n;fOq-jV-veG*S)oKC!Q<1@QyLr`B$Y}< zH8nNV*Vp&P-3Y<@`uf+v_xJa7b92Mv{r!DXtJPFmS}L*X>T0U1t0SFGN28;o9FnW6 zD>g-7#L3}s(0|F?q1l>7=ElB_1PwwY9a1pfHokBuPxW-7XJ?Yc@7Ecxjj*%%KG4|6OrK0G|6ii!#rs;sP}%galenVAvA z2L=W>aerX_{rwU!JO_>K?QOEzZ1Ujo@o}1-o)+hbErhgME$!{?iA4mJ3snjQ1{RCO z_;3PPfX&X%(#Xh&JQ!l#-QBza^ZNRF#n%Z}FdB{0{QNw1b#)c&V(*qr)aU2tXFj49m;Q*qwjYbLd^_~6nLtuAzmsVF-DWA_v)-#z5 zw@zX1_xp?L#fqLj0N_yoVRS6?TZG}{DpFLBR*^s$F*y|}pGx0g`Yf8W)=;MevC Y+7nsk*y)v%KmY&$07*qoM6N<$g7tQjBme*a delta 944 zcmV;h15f;>2D=B48Gix*006X7IrRVl00v@9M??Vs0RI60puMM)000AUNklwh<$ym`H1yCY3w#7W0tojn;ec;KBQ@F87@CIe&A`%sqEh7-RT5s`*RY z{{hXiY&xC(zQ3yaYZL?_5{XEXWVhS3TCMDyOeR$-Rc~*v*=+tpbR-ff6bc@XN2Aev zGGUCv;V?p|udna7(MLx|E|<${wWd<3wY4>dVT#3KcXzkn?{95wm3>&2y}7xeDC#%R zq9{s|WHOl&iGRe*%nZ--C6ivS*XeX-v$?0I$LI6Cy}bcIV`JkNXrAW-fdEO8qobqp z&h+&3<>h6ik~=y&g25m{=e0;nL7XT88 z#P;?!08kXw+}ymiwe`_jhR$ZQ4u=CFluoCorlw-CSbr6+ESjbP03k$@QkaC>_zXDzAh$bYabyRx#<-{0@`dItsuGMP-JCVYIF zot>TQ>+4V`w79rfngO5DvN(>jSS(9RO9uxB)wykLZOhBcPNx%N{Obr)nAaMOhNkJc zxjBo)QibbuI`{YYola*o8oj^2|J6YOO%R09XncQv4+eunLqiB5wOXxKt3Uc6gccSS zRDUWJ$8pck&n+!23dwN=9UmVjlSz4<&*$Uu_}JJONs=y?%j@-?pPyrlS(XijLX`;B zu7azptH;MjilS<2eioO@<%-3k!C(M@v$Hc%6e|sD`0@f1Me+3XRIOGwH8s`O*XMFM zNs>~jRH0CCyWKXMtxD_{=#ntT4-XHyTq(|Gv(?ttekbI+=s(cE@gMRJp??4}CCL1T S{If6s0000 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∓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 diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index 5709ec13c39f43d38040848f1b754ba24b8cd51d..5ce2668bf23b26c230c4bb624137ec0e8d8698a9 100644 GIT binary patch delta 1164 zcmV;71atfJ3zZ3w8Gi-<0016@S^)q61YAi(K~#9!?U?B+K2a3M{Xv$pCsJOJ{RJgk zge=7iFKh{kvL$Ke=qRG#Rc{C^?y-ALj$|7sHmX){CsxZ z+S*DT9UXLZboA$e7Zw(%xVV@>ZEtUX`X0R9+1bf{Vs>`+=Yg-TuBf!Ml*Y%$>HYm( z=kM_D=;$aF6cn&Aegt@PbCb%;%jy39UeovV^u$6UGc%K-qM|4@HI=%%yXo=qQFe@* zn;R-DETo;C9e=|DZ)|K(U0t2Xs|W>mcXu*1HI+0sH#b^dUZ#VC16o^KW1#%Ix3@P2 zy|=e#IAHB*1gxW@qb#gkGdDM_uX=P7_E-o(QD zMjxY%F0SLFcu*ep3rV|bTp->rz;1} z%F3dUkbe-({p94N#GtFID?L0sP;+y$Dqsf(2Z`N;ganF@k5>katbnQK+5jylCr8%c z?d>g#Jzrm6#lY6q))I};*49RrmX7@*dwVkw-iN=IGg?$s#GXSVsPS?{ zM1*AfM!K}Iv60>OWr5q<+gW^#jErdd($dmo4S&KL*vaq?jH`hb=6MJz*siXwRu;oa zNlBtu>gnl`HD+dJ#gc`7U{ zj0qt0t*)+0i~(A1^N5R!quktFQ40zRlDvm>h@S&AzQLOKsw(+Xtao!Um$=AQcBRDo;J|I;QPAuoz3YDZ#s^ zr>FX@IAG-P@Q__YAFG8%%nEm~ySqD9BkH|G!oonlgxJCT#KeSR{a|5X!DdJ}Ceo*{ z*3)ZE2Ur+@SA>Ed6~%xsMxURb*%&-S82lgwQ`y+qDDjxCM+H!L!g}t4i4d-FNW!a;`ucjBo12r}uK$YTR>Z5)6CLA$wh`C} eYy{R%`vo^hnl)GvHZ1@E002ovP6b4+LSTZDNk@YK delta 1517 zcmV9C6tr4iq0pr*O}A`4Ok6ihEsJ3I=YJo*r{8aWznT5)?96Yb zb+g;;;JAexHvj-95WjT-@k@t=Fc=IgD=WtUv9Yny+1dI0`Evk3TU%RGQxgE7wY9aq zy@9OI6*w|R;YPH(W zpFc-NMuvohIN{#gWH1=6U%y^iS^4qfM{0Y2e?LP40D#$SzIX55+S(d)$H&K?Jb99l zkug6%&wmJ_tE&sg@wac^%49O~J+iX0ii?XG5KV`}glj8%5E8fB=id!UzJ#aSsmlo;cXf4@8pSY7sZ^pU`ryF>CJhczAe8Nr}VZa0QW_o&ELe zSATLr`TF|O5?WhZbvm6`EM|B_jf{*;PftU8Y;0_5YAQcJpLY7tA*51ia&q#UH*cUT z5{YODIF4f&1^_sH`ZN;=0Kmk=1T!!}>VRj@o`nXETGp6H~>)6;W%dz*AQ91dxRgoFeK z2lw^$krkcCOK^SL^O!-@Nj5YEEcC_#tDQ(BB`ydRVWmhnVBgmDK?vp41X;x zEv2QUEiW&VEjpd<_U+r$nIM-Tlt3Vej*cD~8EI^6{PgJ)oXGtlghHWGsqE?LK~WR{ z5EK+djV>)M(ehO))!sp%*lc!1MFoN&ySuv_4(I99r%v~jtNR3oVcy=}D2nFfKo{~i?;Ra8{OoI@ZGNTpKvH7yp)*w|QbaPVJ3003^>xbgDkORLo?lgVmoYM2}u zD2hIQ{1_4w1W{jKpPQTOy377|OsmzZR4Nq;1)I$V0BE(^Z{NQ8`uc8cY=4xOm(z}B zHk(~mR(9pe73kL0)g>h*UAS=Jun+(MqtV#V(2$v#NxLDgudlbXv}iOMgTZj-%o&M9 zl9rYRXCBA#XV0D`B_)wX;;;|^0D>SI8ymS?F8P)&G&?)n+1Z(slSA9yhlPNenVA_F z7zhgsi;s^#b?Ou?>TozFCT1oE1_tg(69fBg7S zP*Bj((P6jSg+gIeR1~#}|0@WpR##Vdc6RptjrDIKjzs^9e`=0IClJ5%pMdxYs4#D0 TI@%R`00000NkvXXu0mjf?)u*; diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 99ab09e8a150a34a6ed9b385b2162b37adc42a15..f9a361ad9327c4ec952b653eaf71dfb988a9523d 100644 GIT binary patch delta 1919 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 g24rIfV@vieWXqCuNJtt>wh*!wMP$U-*U#{g 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 delta 1770 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%0D8) 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

(i%a<8wmWydIZ2di+8cC}7p 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 J+kgHa{y(QU>*W9d literal 142884 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

&%V0&IXsJ+do8V^^YBCl3M% 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%0ALgm&A)lzLqVvI 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 diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index 4ba7ed664e45e7f8f0440b2e50921d3c1f3b51cc..80ded4a940b380446794c3d98d5d219cddb90e21 100644 GIT binary patch literal 15562 zcmc(GbyQUEyYHT%hLWKKl^D9ad+6>KM7pI@YUmVcQR$G7MnW2-6_5r=K|s1&@b2&L zch0)^taaBt_rJp|81~wmJ@0?7z+R#Z~y>>`uA}&J^+Lef;Rxa{<&W&B>)If13;{nh9W-B zWAI7oiOhl_2nmO+&<##26XH+Z&#FJ;2Q5Jh`MAF`}CS3 z41uyBFPe1F`>5~L`pjAI$xbY~mnbFsLtOSZs%a_=M4cHQ9E>Ur>>HjQq7(|HeRRu6 zc;Dj5X$PVW*8SPThd?bV4Ziq^f9RK^4ed4u64zx=GWkflsa@}3)?az^&XSb9>+X2b zvd~{1BEL@kYuHucU$~bk(TmGjBMr;+l16N4l!;=)j$XD?r3}$yh`m+IV^%qPcyTN7 zW8cRb)n}R9Tm2Ca;?neFW>?^qF6SGEEabOS@s0W=7l&`JqrEy;9MWfZ(>rDkFNfy6 zL?d@wR&q~VnhFbXmRC{tf9-A>gMo}oD>c>L41I9s0cCj&xhfg+u>Z^YAi&dzf31(_ z%a>;WfJXA~_1QO6Rdv)RIly|G7RrZ(P4g-Yk54WJosBi$__egODC}{Z5*kBHO$=?E zURY1pr|5{M5l`WyB3eT=pZJa5G^|pL9*>KpFMY+piobI^bPckrE>WkZras~-E|XsE zWIVdRf8grs8uA9o{xJO2EBwa<4<2;wp7)H5;8#>vf7#gBI3z^6z4?l-0%=->*zQ+Ai&2zhH-mCzPP!% zYGxui-=H--RUd*;CaMDU6opxY=_(i!5)$;!o;_<@$qWpmyYC;t&HZ4R@ONv=ju!q( zp)(!r=H>>Gu0m*b%@)h4;N;}QpO}~!tb9E@jPt%fvV5%s{rj?_qN1K!fzjMk_Jh+%+ zU^CMHqF<^IGxTtn+EXCj-OtVa&3_Y2VEP2O{}oJz(CcUbKwtWgU@}%zWq$?_l2Yo6 zDJ9oZ%M`@aGmXtFpbdkVW};>Cg%N&xBroS>(L?bNQ;&prGeHCyl!Ytdbr~Tv$+v#F zZ1u43Oz(2BrNlK-SD>?_vvb=u<2fdn(EoY;FAkXV0Jiy||8!#@gB0DVUnl zdun8K^%oGAkkHoFCOkhsAK%(4>F@k|b(ME;;4(ZmRssfOE{2rix=;-`DInntG9QC{X38XYt9`C=lw6vtXI@sD8u>85Z z8(--j5~3+9E8Btv{FRiHG}tN8)z$4>YGVz3A3GZy5MU*ETt~&Nj)XaXIoSeVb*T2jdJOO;Yj-)#z+1b_d-NEVkv9e+} zhFDUOh|J4Fd~V0X!>b7>E-v0d13Zej#l*xQiqhlSw&IM83JNDk%Azo%r%zMn0|EoB zyu42rAu~3 z^52d#W*0QA?lm+u@ntv1l;Z=^rrO&1-D?{ zw}+*|UX~}Ng1}W>bJEh%5{r5v0|Nt8!X8ye8$nhG10K@{)1bwto5pr~gL~7#!$ZeF z4O!BsKu1ScR#lbwZDa)FW#B+mSMe3EnAq2p2tUg2;5eW3%aDS&!X8dwZ0kB^S>?(c&k3)4kIhED*~lIG^jXLObMZBDKJSIxqM-O%1N89NE^ zGHzU?Y2k(}$_WjKjO&z~sVFa>aMdMvi7!jB&Bn}Z-u+>Fv#_ubW49p6+Qz1KDAEkF zIyjz8Q*+%?S;;Z;kaomU1uLw?`Skes7%DaJVf?y`Z+>#f9QP46hRcf=>f3w)0RhhD zk+xVx@O^MXc-7U^U^%kO1Mdggeh=6cu1YXpmaDY0b~}?yN$U?3U;gC$FyY3cmOK4+ z-Hkd-;qOy!4sSqC z_L4={`O9;)|I9is)BghRBYdUpHk%~>o2-Zg&m;ep70VsmmqDiapIPy#gC>bOd=o3S z0EGxcPm}G7fxm*MqZY|YL@f3AiQwq!M=||ukELlA(c$7qo@8ATJvpS-F}grOsUO4n z=C-w9eSLd<>-W~K-M~@;v84swb2Z;-UHuoAmxs@9-t=jpA^*$EoblI}Xo9ng{e=)L z)Zt2}uBIlz-TmGByX9tgy6c;poqlSGmvV>8?YAM2qrG`PQTH`;FzU6Po!PTRJfj$y znC6Y-fJ(Ek&u1-Ptqs3#{U z^9u`?iXMJ_!^O|TV=?|i_yG+Kjn~#__B?jie7)Vg;hWJW)0#W?Wh+Zd`}sN>e%EDj zMlc(uWn?H^9xU!`K0wRs5A)s>5sgv4#YWwBWrmyDC&p^e^Yw#udz9aU*uA0^VITcR-@PsVjN zBTpXb`NG8|r(GN12^ueuw|!F0i2IN|udPkIT`4S^o1fp>1n3C5?avwqpFrJZ@_cs| zm&c6ry^!k%&0In$%)60_5h(tV$Zy2 zZeTQ9Y~bO@$cVmol$+@czbVl#0%2Qm3C?FNy_*!`ep(a&%-!fyO-+*6^3;?Z_&|-Z z$0;bsQ!fW+XY2JCq8^~@%|L;TjSZ&N-QC?U5H)qzX~;NDS9e}s9y}AwNlT-D0Jyoc zKYjM>h1=$ArHP&AI?doi|CTU)ONu}lazM$Y=J)hR!}FY#Hh*1x<(h`s>gV-;kodN=CXorFVrbfsZ5$Xjgv=5HGKf` z7ejSQ3Q<1L{Q0wclL!veDo$oNrML|ce0L)saIt5Bi-Cdh$XP^J5va2sA~Lv3N1A;0 zDAt0jsH)LuBLDxUosA{Gq({hg-jBse(u?E2Jh+LvzTBLuY;A9%m<^$mXD*e8hJ z%^3?@l#m13T^}f^^wA-yIlXH9)1@ms!rHeB{W*`d}uC2$ayXgcLebQ8ynkM zvTHya{*)*jP0l{qucM7V$}Fd{UK+&4_T~I3SEQqh@=8}h0dowY%9NFr)ypyMpxWZ& z(pxa7NAjj0bXdgQ;Tx%vTI&OiFBU#v$(WjE>EkOt!S6Gox~%^A zv0ehGtgNgcW4=@P`DJ=~+7TGkb^1ItGqb~BD!7jaZgp)#!#4+);aCJlQH($QJ`MY+ z8}ch7{r#;3A%Gl{sOUQdMaA>DB4)){bkr=G9J#{J`s!*|+kkNURB6JqKN})YA_nVt zbJQS=uu%)yaF&^u(i^}wNZkix;E}8p=m%msnK=CB}wojb_^*IIqVPC;6qoc zmJV2ZMui03C7OBHc%kB>PD9@QGFXRbomSfC54%UsFQqNb%~1?|(FD|jyGG0y`S=Zg z)!z&9K&`h0T8%xmi54C^@SmJcmT2<*V7E#6y{^13D3`c3mRn!_Dhiht7vL_b5@MG1;zW!r(1bI_ z{{|IN;Ca-)Q1OeT#aGZU3HcALkiC&gqDbC<9bSV7U4^s57jbZW<5tm2R#)9L-Efo< z`QKL`v)hw`YV<(Qdq*g}c#~1Ab&{{%SEBVWdTVn3+lZIc<(G#mxDd7O{e`CKghzaO*`nSRv8*~pF^(%8?eF;P5=Grskv(@{ z-dlGBp?oG{;Pw{8n(0VfvMCU=^*e*_^sY}fD*r-0*VWfgm+6*70RE96FZf6$@v<}w z1NZ53$6U#vDUc$5o>eKe>n zf5>H4ks}e1u{(Bruq4*-@_eV7jX-X$$u$>_&~NtKO1!z=%B_@W2TwSHpRKUh=SQY0 zjc}+R`T^NJvaBKP zT4-|Bz5gTTvscrG(a5%_OM}9kseWD!0)cJN9Y`1^J90QIXRDrpiSTP2#H~70ir|RC zajMk9pAbFJWx4GwZ)>spdcQ#)3m%ajI>dG~%RznO*HCI@kp9-wjD_R%fsY;=!yB1L z*5>isKN>!Y`s^9~K0MtVc@l3Qi9=fFj82108M4dE;4t~%O}Pw&=B6;HI;sbXBO_`q zU&M%e{nm2Ypn));$Dpm9<9!D=!>1~%_%190(PN+07g^%|yk5S^z=Y6Bpq1Y0APl-X z$#=bX*AhkRVwjmqjpl=ca@d4AhY=Um4}|LyDJo} za*!cejixKadQ`WXSkD%0`hU0sYYBz=C{6lu*fi^a92%vD+iUNU509{4;8Y8{j- zL6_O`5d_qdesQE%$8Nki0DB?0;dot6_Pfs8{EiDE!gL`O_Nh!#nBqhe)ZagRkJ0l$_s-`D+*GLZ@?g0=;1>)40cKMq zIh{H4{3};yraG|r&S+jwoIAAT=d%C|xT+%wHo&OOq2^AgjZl4U9SD#Jd3 zNsE`mIT)26`RqnZ!L)Zfyvl>1cPRj8I{?LM!*w`fRQm!>UPwM2OwF3qQ^5yI2c-TS z$tI$I1=DTNlK_zI)%@8h)oi1@x(_y(HoCLGBcJUj1PpYNowy1wJaca^4?haN_)#?7 zJAj1EX`ZVYXmm2$D-BO4314g zz;=PU&i{1tN9v*J&II8U=I=y~2EvN~=f&o6aj3Z85n@2L(Rp5H_vvuwM9_(JI;;Mt z0iPeA97GQC-}8=kdR%g_P-?zgf;*k@01 zlUP!z!!__j+toN@D3d*)rma341F{o&Z}%RH`VeCzPLQ^U5L(h)?0TzidPD+kX?HXs zH`^sev)^*1RxS}l)=z6KBh!5mibw0Or4*t7-^1l;&NubXH5pRe(ev!a?vo?}F4SJC zgBdc2pnz{W4xcE!i!i-M)Jj$BTd+g~cOvjfW}ez(Z>|$(A+0OJ=cBt0Y!`+lu5SmE2*>BlmLRxVGW+8|%3j)RWu=2Nmru2oMnDmqj z50mx(0)ts>0F7uwbtahfQ-#yWI|28@Rxq~~Vx#uMx+}y+)85V>qK&&QHYYcsBmO)Y zi@vlhCW2%0CME5G$;kas9X@PLRP$6+AU@^iYInE+UUzUVm?tm|Scb^xuZgQ)Vo*|Y z%}yRrdLfddVtrjF#$r8o7*QHDoi98`_qk)JLS3P~d~0xnlZjT%S0+XyPx#IIZ>eh^ zzDT7Di+(_flSK~g#ZEu~LEb}-YwI=-!clrF;kUSnXHD(x7r9ieEcoZl8!wk z@pw7Sel%s@zI|RZ)w&-(O5PItorTi3as-IAokzg`d=f3Nuf%xW9JX70-nb-{suj za(B`;RfpnGOk$&O-f75wLqX8KeZ@eel$t=EOwQoqH2s2Mm)9hW=vfY@v0x9xmX;h3 zAxcbQ8mkVUwhn$68~Xp#2iC3$8&o7Oz6M>LRJ)8o zX;aj*H1h7UIlP~6b@8)jhfW5wCk@_)-RgJDxLJlTaqwbV-4fHFy09cAJ#_EgnUT{5 znB*;XagMM%Ek8lHxI{ss8Q}*R<+>l(c4uRQ1zJpyBNOi`MRAq{0Oy29)WdzRGcA34 zhu}1z%F}26O1f^w)lN>=P>szHhE!lx`iXWPB>MAtld166ve{dH$0_ETDe~wC6-5j&>2^ z*B>s}!?zzusxS`q)R--kJSVmkFms-4^ zDVw)kJZA-Bv(~u-uZ|+BT<1i;> zbk8ss-nyl4#CoDr+vZ1(C7GF^Q>Kkh*$380&_X>3dqAgvJUM&EL{+!Qihr@atpfz(8Ll>F0`}__Ut@% zg*n2}lmejeYg$}Ii98b=pS9lDDJ)2oA3x4$rFA?OPHO(BQm`{&M}__l06ub<7=ae$^-J!lY08CS?>K&=;af z$56dYmfARE6nfeJb&nxs+G2x_lHwCR2M2AaUSAy%eX5wn0>anz-3wG^{pju5L4J~@ zjP-XA{U3W|;faQrjqMfZq8yFSP+9WCy^GkwG=`~;?TDriQ*yjqen?R^1%+Qwc%TeV zcwlobF9(jgkw$hc2Hv-1I>ji-W3{#F&iiuxY+_MMzd5XXlr(SE*LCpweEJ)SZP^U_DXG+-kSstvj{K9^kI_^wP^V95eBWq%rD; zoYyGbpm8jc!A}Xh?$eXSkOJ|Eh(=iF7GX3^vLf>nnL1G$J3A~<%4e{=vi#SQGzOw} zxK^Jw3{11-9w^YA->Hdv2wPn2Vng^f*<_J#VYDuEuSl8g{gy)7?Vp5PA1awN<|R^! zJ^e_~&Fc8LXJVHa8c{G0K99^KiEnq_Qwc({%?R>POmM~$QCXX&Go8!QfIIJT6e z4}$d4W;%a}!C12sj(Clx9N~DpUB+v>mL;R+BjTUo-bzWhJ5XX7OC{OU6eIe6CkO@! zM9C$#t1lQwgzdgH>@S8R-ZbLji5Ot^=J0w`@MipPfdjFI|9{CH6yolPOQbNGUkJ@y z{r|1oAmC{T@GsqF>QM?7(J7z)r*6A^L((_fe;1Ayqoj}mB}&5}rB}+JPf2S|kxo%H z@)$)}XnUBMGvK6zqfqGBM5b~i8(B9wB`bWl7vDywyh_Uta{Pm@D2@hQF3i_;ZeeJjY zN=V1-NhjH*P~eqYEohu-o0@(*d<{jmxm};w?BcJ015P96Q~>pCuOf92{mID*g?h_p z&#eriWU%OU3?keoservXp7AEv=i1d~5{j3Rq+fa>ar>59eXrYffS82c+3NVdc(Qm< z87MP7WLyEML`6vax)|5(F?H~j^smj$IFRzk8`s;$f)Cq#Px+c!>LC^Z70Kz=7}@jS z>*59mCGRm|o8O@Jngjjlbu1Eq2<>*$=4(bQD2zU2iDw$gM&S{H3J`WD`Gk!uFbCAS zF(9vFyc3VnH*XEyhYbK22)5528*$_!9!1R0I|9qF!#4cmA97R(or0coW&JI4aKGJH zPAoX;_)s*g4!Ig6^BV9yTB2lKIp-BHoO_lXhLWRz-xL95N}~s&ssCspg2fd zx9tgranLZ(L2G~7;5f5ze}8vteCrJN`*X42Dj$xCpHc;@v5eWdh=wL@fT{qQ@K|72 z6sMlRnSP)BdB@`Lhjq^zsT`>gPC?h@EPi+#F=4OtRTeXl{OFr-j_lJHmh{xFVt+y; zOQs@K{B}Vnw@EK-{2z^pL3#~dmcIdtZUdRbpd*7aoj#SepYu(g_xHE1s&d3u=!3lA zj^)oK#6Se1`Rq)Uxql66YpB6M*5TzQU%tZ2W}mKu_uaA zfs!P^dN^U(=h$1_j`MR*&@w$96+28TxBQiXf|{dSZ(GzYT;6ccBPkGgm(k^7u<^Hi zb``~Z_ib)J!&o_9t+?ot)H`mv;vi2f5so9 zMmiD9RJ~2~A}B>|J|~3fiMDE!%Uq4c&{nBde%OX@SFI}K2>;}K-=;-4TrfE|F&6Y~ zUTSv+2UVs@KDx@9vr^nvkDJ=v9C`mHFlzRB$dU(gll%kVw``U8h>y{wEJ4qVcs456 z%u>`@3keIpJD*hwv;#$W$?v&ZI(Zayl_0I4$Ae8Usb)a#b5$(weD7}`a+CwM<9VT_ zjHPQJln@27b^(m8@a>Xk6-6!^)e@?Qy@cY4M zbD1s{;1N(aqNWiH8$H{eY{0tGJMPA!juG=ckh3J6Ez!(v);FCBdrtydM~P6T)rU&7EaV32>0V-wL@>F;(<8121{pvTX7J z#6F40+2Ex@uxJA9_$#c{L+*XQt0RlN%b1AV8nP=n%A(61PQ&F^-)fseOeV%pT;WXg z_lZ_2Sz3q@k#d&)WMUu-*LVMifJ6MY9Oy4TEG66oEfry9d|;B~SyW!>gqKcfe|Q5AA{Wv`SkpZTO$KtLoxOG5O{e1Y z1A=)gIj4QOGaaAGgRH?ayv{B_;eMzwJHV*Y0o{N?z^`^#We@f~0d%yC6?wpfUIz^Z z{ZOI%uAiy|tw%M&*S2DAo;nhkHKcvG7&gPqJ06pH7z@%kDAl99NeQE@ksf6s=FqS^ z{r7?|gtuh|!C-q~P2oIOL-Rsa@K;zwv9Gl?de_0uyK)2Z(&#u-^y(6#UcAh-4@BK? zdyO}2@EHyHd{Wep1}D|Rb~it4+Uk(r7{X-tqY)o-B}Yf+xd1}+Ag*LGKyG}JA*cpd#Efd+m$ez6X+_InHA8l(>V@5tmeBf%F}i4!nv*2d&JHI( z?o&Q0d3h+iKaU*A3j)4L{^JV~{6fFZd?Gfk$LPwrS zb1C7V;vZ^!C~voXZU(xkBF4PrOX>MBltWc3pl6JOdT$z#>X2S<&+Xjnu0`F;=JfgK2K(_a=xSO)0qypi&tBU)HnTgk z)znN%56ufGoA>0yI2ggF1&iJjT%I5YQ>jA3wPU(iqrb^KiULu!Z7M$wx|&2O=A+c9 z_Q}`4J=ZtCLex ziRbQDMuK`yC|)rL$KK`F2QF#^2X~xFes7eF(a=vO1e(i>7T%9#@_cD>m(@~rqL0gS zo*KDZJM|#C{o?vfNgqF6nI|p_KtB3;o?BJEdvm_qQS}c?Mq(8%xb)2(wB21SWDQE1 zM8E$%wOjO_G%-hiqt-eBSgnM8L1)_93a%Ci{&R)IvH};a53J;+QN^d6tEGp28{Wn0L9i& z3X@kR>T3J43Mb3DnUrsjYun(L6y`cV!53GN)=6RF;RwRpVNnP&sBx0lbdII0#;5-f zruyy$m~@`4NL3VzB;RdWHskm1DB0GBKTgWiz=9G2MLmL!Q-kAQq{TS()u|>ywf1Tu zDp`p>-&fje4FVQnd;>LG0y$+_fYM-3$4$SjQA(>N6Sc`AW#U=3Clqw+Yd;Dg%RwbI)4k@rIBW@H#N7EcnG|G1w%0-Mp~Up*EOP7Vr-;6*o7 zr@$|GW7Bdg9mo{pa~TQ)0`Ddzl0Z_#8gv^DPIY9RH|aZ-=4%SDqO(Sy7^4gu{QA|? zON!BwI3JF}eqWz^7=_^wVVLPx=mUw6dHciZX<28f+!mjwD18H`-RaZ^M zT<~!pS(Vi5)IH@B4+OqglE;BpMVaVkqA4x>&ry~^YZ+^?oHI=V8lg0TKB;-%R_}w_ zBMs|ZCg`AozDSjJA4@$}{Un$Tk!%WknP#*39a550W4@E2(U20cByUq0fhCln=T4w2ollkEa`)i4G2b)k zFP*f$)io$(9I6c)osEx|d{=78nR&P~HPH3gXTcgR)lT>CK7J^+Jb3rU=U|D>kT&1XTB>sv7N9)>lD20-jOfY2$s<*5DG)+svedT{0fP9_*5*fF zsq*5eoP_^w;GpJ0HH%i zf`Fp%^oLCwhacEYb|wuri|9Muy5~TxsaXSWldEZ4FF&L1+Ydm zkVt7og+w)14k^iHqYon{W=3lK$GLT#s-7Mt>o?v5iv1G2+aj7UWkTMnyC5Upp7zG&g!dm>fF5?&KlL|L-}^t;3Yd>md>3sa-~PsnKs+p%o+H{upH0H}76x^g z4(@-whY^Qq?0r+xZiL+uWI;QILhb4wG>*rG43q|LdoUb=BZO| zB>P|<27vE)_prA3w4n%KCPFc&X4tn&;)#0We*%eebITi$TC3=)-J!}=n;yl8c1h&n zi7G*mkasISzS{{>;D?0j*AFZIdN}oBSLND!;ZJv2bsP42GGbk_<@kxAOl-^ErSHMw zTWH>JqiVXWTR8`+Y7%IgH#edZ^H#s;0rju!Y4*BV7zxnHut_ddw?csH3xX65E-r}O zuJX@j`?Zs*q&I!2h3&s1D#NL+2`sKh!<`pqYAj?Kax&_i@*2cISOsa<8_92Axzwrk z0_;<2xcK0EsxX!PCa=f#8ENyIJanYIlgpInVAb)o;d5;u%LA0aR)V9k>?VzByXIp5 zpxP~RO=<4qie6X%whHk5eb3=tuCTkz_PDD7E}u`sNM6x3y(o(j?cd7wPE?1{iO zaNwgzk#zIM>2@%!`WQY=cBe?U$&566yr2<5$8w^B=t*zE2Omu%L^)oa!KYl%TQ7Y>f zJJznje(DMqF7h&f<%13vr}O+Cm(wNJ<+h>Nl8}6nY;q9htH#B z^pjqFMs>iD9iL6Vymu%i6pu=LPB$u-m|6jy)DEn+Gt|mENXlyfdv-O05M&&K0Ve3e zH0^yjVoJ=+J9ByR;+@(T23zS5IkWcS2FWnY3(zdeuE?*kF)bNgdoT84`>sj>UJ$;;M#JSUuC%(juhs(3TVR2fLaz@%TOs}+G{ zX^wT*(b9syQS9PCVnR+w=H9$dOVva`Y z(}yFnNxmBAkO-qmPDzs`cZ=($WK(4<-d<{L zGNA6X_!8A;9I03tPJq#+HH~3E9+Dz>DRb zMswWRFUkb}xKX4ahP+li?R%nXqlO%4gf4`08?w^EP(H$d>jH0pyJ5G-xNUSS(WX;^I}Sh$@L+V2)xz7?&2XW^mgK zr)g!r?HTa}8?+SKsP;t2l=|1MiUDQ_!x#OJ2Cu|aFD!YedoRdw@Z-ug5mco*N%ONHIZu%`_-MeP$uSUCCmVj4Em2eUQJpNM^r&BmkY8R6 z6KdCa5B9T)n6UsaLEk3d9*l&ce{>&c#n7hjfz~M_TZVR-4g*6fRe(#;yd`#3y@O(1 z)+?|tYe4-TEOfhP=>TV83}7p=%OX8FpRHP{CQPSYf{!_)l2YZGGm@v91KpaTK6{UH!1rCQ+_i6b&aY_~=!&{&O z|6-LfEWsJB0)e4ti7dniu5tj?$ZTcKc@=l}+Vpsf=gD3>Q$s{dlGHdza|Edn4{kLW zHfvy)X`o~rGb<~R_s&#oK>y%q!jHyyXi_&-IB4&&yOTj-bu>JYV*_HKkKee);e+c_ z(G2z`^l!;RU|a4c|LBipy%!6O8&uP1u$_6dpgd5X7(1+}Lt@}cqzbk164aCfkzf{{ zk<1wTE&s!3FV=*$;zN>N?;H;3qs7W{w<__s%Oz>~`k3mqle=ZT^CQ6?LxF8zr(pS| zUC5Ird zc|6W)2@)-Pa5ZF;y22x}tuubdgm-=7V`u3n0cgWMoyr(e6g4tB(d!f{;PZI=QgT09 zmA2t*X=cn=V7>gInIm4y-l?7B$RCZty6CYvkVqyFtYeNs zF-VN=ZJWwL5D{7cjD`e|fnlKFGXFz|H~=;DpD`$197~D3Ct$?Jnwa`OOS1n9e@v8r literal 21175 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 diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index 64b6ed4f5b10ff05ba42a32d946c26ab48009fe0..5c8fa696c6837fad0c5461ed53a6c579efa47565 100644 GIT binary patch literal 17567 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 BSzG`B diff --git a/tests/ci-workflows/build-desktop-icon-set.test.ts b/tests/ci-workflows/build-desktop-icon-set.test.ts index afb1d83d5ab..7af94c1f609 100644 --- a/tests/ci-workflows/build-desktop-icon-set.test.ts +++ b/tests/ci-workflows/build-desktop-icon-set.test.ts @@ -50,6 +50,9 @@ function declaredIcoSizes(source: string): number[] { const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +/** Truecolour with alpha, the only colour type a Tauri window icon is allowed to be. */ +const RGBA = 6; + function isPng(bytes: Buffer): boolean { return bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE); } @@ -83,6 +86,19 @@ describe("desktop icon set", () => { expect(wrong).toEqual([]); }); + /** + * The backdrop is opaque, and librsvg drops the alpha channel when nothing in a render is + * transparent. That is a valid PNG and a broken icon: `tauri::generate_context!` refuses a + * window icon that is not RGBA, so the desktop app stops compiling with "icon ... is not RGBA" + * — a failure that only a real bundle build reaches. The generator re-encodes, and this is what + * notices if that ever stops happening. + */ + test("every committed raster keeps the alpha channel Tauri requires", () => { + const names = [...pngs.keys(), join("tray", "icon.png")]; + const flat = names.filter(name => readFileSync(join(ICONS_DIR, name))[25] !== RGBA); + expect(flat, "these rasters lost their alpha channel").toEqual([]); + }); + test("icon.ico carries exactly the sizes the generator packs, each a real PNG of that size", () => { const ico = readFileSync(join(ICONS_DIR, "icon.ico")); expect(ico.readUInt16LE(0)).toBe(0); From 64b0eca2b02092258b4a45408f96d3cf38993cc6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:39:11 +0900 Subject: [PATCH 44/87] test(release): find the signing keychain steps by what they run (#5353) dev is red on tests/ci-workflows/release-desktop-scripts.test.ts. The case locating the certificate import keyed on the step name "Import the Apple signing certificate for the widget", which is the name #5345 proposed. #5339 landed the same import first under the name "Import the release signing certificate", and the conflict resolution that merged #5345 correctly kept dev's workflow and dev's stricter import while carrying #5345's test text forward. The subject of the assertion is still present and still correct; only the label it searched for is gone, so indexOfStep returned -1. Locate the import and the cleanup by the codesign keychain commands they run instead of by their titles, which is what the case actually cares about and what a rename cannot silently take away. The p12 assertion moves to the trap dev's step installs, which is stronger than the plain deletion it replaces because it also fires when a later command in the step fails. --- .../ci-workflows/release-desktop-scripts.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index f008554a142..15c6cbe3a18 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -220,6 +220,12 @@ describe("widget extension signing", () => { }; const steps = workflow.jobs?.["package-desktop"]?.steps ?? []; const indexOfStep = (name: string) => steps.findIndex(step => step.name === name); + // Located by what a step does, not by what it is called. The first version of this file keyed + // on step names, and #5339 renamed the certificate import while this branch was open: the + // rename survived the merge, the assertion did not, and `dev` went red on a test whose subject + // was still correct. + const indexOfStepRunning = (fragment: string) => + steps.findIndex(step => typeof step.run === "string" && step.run.includes(fragment)); test("the release build hands the widget a signing identity and forbids an ad-hoc fallback", () => { const build = steps.find(step => step.name === "Build WidgetKit extension"); @@ -233,16 +239,16 @@ describe("widget extension signing", () => { test("the certificate is importable before the widget is signed and is removed afterwards", () => { // codesign resolves an identity through the keychain search list, and Tauri does not build // its own keychain until the bundling step, which is after this one. - const importStep = indexOfStep("Import the Apple signing certificate for the widget"); + const importStep = indexOfStepRunning("security create-keychain"); const buildStep = indexOfStep("Build WidgetKit extension"); expect(importStep).toBeGreaterThanOrEqual(0); expect(buildStep).toBeGreaterThan(importStep); - const cleanup = steps.find(step => step.name === "Remove the widget signing keychain"); + const cleanup = steps[indexOfStepRunning("security delete-keychain")]; expect(cleanup?.if).toContain("always()"); - expect(cleanup?.run).toContain("security delete-keychain"); - // The decoded p12 must not outlive the import. - expect(steps[importStep]?.run).toContain("rm -f \"$certificate\""); + // The decoded p12 must not outlive the import, including when a later command fails. + expect(steps[importStep]?.run).toContain("trap "); + expect(steps[importStep]?.run).toContain("$certificate"); }); test("the script selects binaries by Mach-O magic bytes rather than by name", () => { From 480c1c7e32936bc6c5b26cbc9d89fcedc02bdb24 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 01:40:09 +0900 Subject: [PATCH 45/87] docs(devlog): close the round-2 follow-up unit (#5360) Co-authored-by: codex --- .../260920_round2_followups/090_closeout.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 devlog/_plan/260920_round2_followups/090_closeout.md diff --git a/devlog/_plan/260920_round2_followups/090_closeout.md b/devlog/_plan/260920_round2_followups/090_closeout.md new file mode 100644 index 00000000000..7d491dff65e --- /dev/null +++ b/devlog/_plan/260920_round2_followups/090_closeout.md @@ -0,0 +1,66 @@ +# Round 2 closeout + +Status: CLOSED. Every R lane landed on `dev` and the branch is green again. This file records +what landed, the two incidents the round produced, and the rule the maintainer approved because +of them. + +## What landed + +| Lane | Pull request | Subject | +| --- | --- | --- | +| R1 | #5331 | Complete the provider-table transition on a paginated OpenAI home | +| R2 | #5338, #5351 | Keep the verification build out of updater signing, then assert the executable the bundle declares | +| R3 | #5332 | Make a failed browser launch and a failed account refresh visible (#5261) | +| R4 | #5342 | Rework #4942 and #4989 into one ambiguous-resend gate with one grant per request | +| R5 | #5347 | Derive the four telemetry pull requests from the landed recorder | +| R6 | #5333, #5345, #5353 | Usage table readability, WidgetKit Developer ID signing, keychain step location | + +#5342 is the one to notice. An earlier lane had ruled that #4942 and #4989 must not each buy an +independent replacement send for one logical request, and that they therefore belonged in a +single reworked change rather than two. That disposition closed as an implementation rather than +as a note. + +## Incident one: a default flip that no test could see + +#5271 removed a hostname test that decided the `developer` wire role. Deleting the inference was +right — a gateway proxying OpenAI accepts the role and the hostname cannot say so. The +replacement default was wrong in the other direction: forwarding to every destination assumed +each one accepts a standard role until an operator marks it. + +Three lane dispatches died on `400 role 'developer' is not allowed` within four seconds of +starting. Nothing in this repository saw it first, because every test in the tree was written +against the new default and passed. What broke was outside the tree. + +#5334 made the key tri-state with the unset state on the safe side, and then three more landings +were needed because three suites still asserted the forwarded role and the first sweep missed +them: the Lab conformance vector in `src/lab/` (#5341), a suite whose messages come from a +helper rather than a literal (#5344), and a suite about documents that reads the role only to +locate the turn (#5346). Searching for a string is not how you find what asserts a default; the +reliable question is which tests call the adapter at all. + +## Incident two: a verification step that had never run + +The `macos widget + bundle` job failed on `tauri build` because the updater public key is +committed and the private key is not in CI. #5338 scoped the opt-out to the verification build. +With that green, the Verify step ran for the first time and failed on its first line, silently, +because `test` prints nothing: it asserted `Contents/MacOS/OpenCodex` while Tauri keeps the Cargo +bin name unless `mainBinaryName` is set. #5351 reads `CFBundleExecutable` from the bundle instead. + +The same shape appeared once more at the end. #5345's test located a workflow step by name, #5339 +renamed that step while the branch was open, and the rename survived the merge while the assertion +did not. #5353 locates the steps by what they run. + +## The rule the maintainer approved + +A change that flips an existing default is a separate approval item before merge. Tests in the +tree are written against the new default and pass; what breaks is the set of real destinations +outside it, which exact-head CI cannot reach. Two instances landed on the same day — the 1 MiB +queue budget in #5182 and the role default in #5271 — and only the second was caught by a human +noticing that dispatch had stopped working. + +## Still open + +#5261 keeps two remainders: generic OAuth and key login still discard the launch result, and the +dashboard roster keeps last-good rows after a failed refresh. #4191 wants the SSE fallback and +#5180 the shared cooldown, both transport and routing changes. #5292 records the Logs page union +restatement. #2366, #3748, #3983 and #5063 remain deferred with reasons recorded on each. From 917d690ecb7df421f6626cd274982cdbc5a6dfd1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 02:08:00 +0900 Subject: [PATCH 46/87] feat(brand): render the dashboard and documentation favicons from the app icon vector (#5361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(brand): render the dashboard and documentation favicons from the app icon vector docs-site/public/favicon.png was the dark variant of the mark: white on transparency, 36225 of 36864 pixels carrying alpha but every one of them RGB (255,255,255), exactly one fully opaque pixel, corner alpha 3. Composited on white it is a white square, and a browser tab strip is light by default, so the documentation site effectively had no favicon in light mode. favicon.ico beside it carried the same artwork at 16, 32 and 48. gui/public/favicon.png looked right at 128 but was a bitmap no source could regenerate, and it was the shaded artwork whose engraved prompt all but disappears at 16 and 32. Both are now rendered from desktop/src-tauri/icons/icon.svg at the names, sizes and formats the two sites already reference, so neither page nor config changes. The renderer, the RGBA re-encode and the ICO packer move into scripts/lib/icon-render.ts so the two generators share them rather than keeping a second place for the alpha bug to come back. The test decodes pixels because an opaque corner alone is not enough: a plain white square has an opaque corner and is still invisible. It requires an opaque light corner and at least 10% of the image to be opaque pixels whose luminance differs from that corner by more than 64. Both ways of being invisible were applied and each turns it red. Starlight's logo-light.png and logo-dark.png stay as they are — they are the artwork the vector was traced from. og.png also stays, with its own pre-existing mismatch recorded: the config declares 1200x630 and the file is 1536x1024. * docs(brand): show the documentation favicon before and after on a light tab --- assets/pr-screenshots/favicon-light-tab.png | Bin 0 -> 11933 bytes desktop/scripts/generate-icons.ts | 152 +-------------- .../010_favicons.md | 54 ++++++ docs-site/public/favicon.ico | Bin 3460 -> 2617 bytes docs-site/public/favicon.png | Bin 16096 -> 5407 bytes gui/public/favicon.png | Bin 16089 -> 3512 bytes package.json | 2 + scripts/brand-favicons.ts | 97 ++++++++++ scripts/lib/icon-render.ts | 162 ++++++++++++++++ scripts/test-layout/layout.json | 1 + tests/ci-workflows/brand-favicons.test.ts | 174 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 12 files changed, 494 insertions(+), 149 deletions(-) create mode 100644 assets/pr-screenshots/favicon-light-tab.png create mode 100644 devlog/_plan/260921_brand_icon_and_menu_bar_mark/010_favicons.md create mode 100644 scripts/brand-favicons.ts create mode 100644 scripts/lib/icon-render.ts create mode 100644 tests/ci-workflows/brand-favicons.test.ts diff --git a/assets/pr-screenshots/favicon-light-tab.png b/assets/pr-screenshots/favicon-light-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..803ab3b32adf7620dc4d8c8e131138fe690f3b46 GIT binary patch literal 11933 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 tnhls(%;s;=?tf { - const table = new Int32Array(256); - for (let n = 0; n < 256; n += 1) { - let c = n; - for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - table[n] = c; - } - return table; -})(); - -function crc32(bytes: Buffer): number { - let c = -1; - for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8); - return (c ^ -1) >>> 0; -} - -function chunk(type: string, data: Buffer): Buffer { - const head = Buffer.alloc(8); - head.writeUInt32BE(data.length, 0); - head.write(type, 4, "latin1"); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); - return Buffer.concat([head, data, crc]); -} - -/** Undo one PNG scanline filter in place, given the already reconstructed row above. */ -function unfilter(kind: number, row: Buffer, prior: Buffer, stride: number): void { - for (let i = 0; i < row.length; i += 1) { - const a = i >= stride ? row[i - stride]! : 0; - const b = prior[i]!; - const c = i >= stride ? prior[i - stride]! : 0; - let add = 0; - if (kind === 1) add = a; - else if (kind === 2) add = b; - else if (kind === 3) add = (a + b) >> 1; - else if (kind === 4) { - const p = a + b - c; - const pa = Math.abs(p - a); - const pb = Math.abs(p - b); - const pc = Math.abs(p - c); - add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; - } else if (kind !== 0) throw new Error(`unknown PNG filter ${kind}`); - row[i] = (row[i]! + add) & 0xff; - } -} - -/** - * Re-encode an 8-bit truecolour PNG as truecolour-with-alpha, and leave one that already has - * alpha untouched. - * - * This exists because the icon has an opaque backdrop. librsvg notices that nothing in the render - * is transparent and drops the alpha channel, which is a valid PNG and a broken icon: Tauri's - * `generate_context!` rejects a window icon that is not RGBA, so `bun run build` fails at - * compile time with "icon ... is not RGBA". Leaving that to the renderer would also make the - * committed bytes depend on which librsvg drew them. - */ -function toRgba(bytes: Buffer): Buffer { - if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("rsvg-convert did not emit a PNG"); - - let header: { width: number; height: number } | null = null; - let colourType = -1; - const idat: Buffer[] = []; - for (let at = 8; at + 8 <= bytes.length; ) { - const length = bytes.readUInt32BE(at); - const type = bytes.subarray(at + 4, at + 8).toString("latin1"); - const data = bytes.subarray(at + 8, at + 8 + length); - if (type === "IHDR") { - colourType = data[9]!; - if (data[8] !== 8 || data[12] !== 0) throw new Error("PNG is not 8-bit non-interlaced"); - header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4) }; - } else if (type === "IDAT") idat.push(Buffer.from(data)); - else if (type === "IEND") break; - at += 12 + length; - } - if (colourType === 6) return bytes; - if (colourType !== 2 || header === null) throw new Error(`unexpected PNG colour type ${colourType}`); - - const { width, height } = header; - const raw = inflateSync(Buffer.concat(idat)); - const stride = width * 3; - const out = Buffer.alloc(height * (1 + width * 4)); - let prior = Buffer.alloc(stride); - for (let y = 0; y < height; y += 1) { - const start = y * (1 + stride); - const row = Buffer.from(raw.subarray(start + 1, start + 1 + stride)); - unfilter(raw[start]!, row, prior, 3); - const target = y * (1 + width * 4); - out[target] = 0; - for (let x = 0; x < width; x += 1) { - row.copy(out, target + 1 + x * 4, x * 3, x * 3 + 3); - out[target + 1 + x * 4 + 3] = 0xff; - } - prior = row; - } - - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; - ihdr[9] = 6; - return Buffer.concat([ - PNG_SIGNATURE, - chunk("IHDR", ihdr), - chunk("IDAT", deflateSync(out, { level: 9 })), - chunk("IEND", Buffer.alloc(0)), - ]); -} - -/** - * Pack PNGs into an ICO. - * - * Written here rather than shelled out because the alternative is ImageMagick, and adding a - * system dependency to regenerate an icon is a worse trade than 30 lines of a container format - * that has not changed in decades. A 256px entry records its dimension as 0, which is how ICO - * spells "256". - */ -function buildIco(entries: Array<{ size: number; bytes: Buffer }>): Buffer { - const header = Buffer.alloc(6); - header.writeUInt16LE(0, 0); // reserved - header.writeUInt16LE(1, 2); // type: icon - header.writeUInt16LE(entries.length, 4); - - const directory = Buffer.alloc(16 * entries.length); - let offset = header.length + directory.length; - entries.forEach((entry, index) => { - const at = index * 16; - directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at); - directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at + 1); - directory.writeUInt8(0, at + 2); // palette colours - directory.writeUInt8(0, at + 3); // reserved - directory.writeUInt16LE(1, at + 4); // colour planes - directory.writeUInt16LE(32, at + 6); // bits per pixel - directory.writeUInt32LE(entry.bytes.length, at + 8); - directory.writeUInt32LE(offset, at + 12); - offset += entry.bytes.length; - }); - - return Buffer.concat([header, directory, ...entries.map(entry => entry.bytes)]); + renderSvg(size, out, from); } /** diff --git a/devlog/_plan/260921_brand_icon_and_menu_bar_mark/010_favicons.md b/devlog/_plan/260921_brand_icon_and_menu_bar_mark/010_favicons.md new file mode 100644 index 00000000000..6e09e5c5973 --- /dev/null +++ b/devlog/_plan/260921_brand_icon_and_menu_bar_mark/010_favicons.md @@ -0,0 +1,54 @@ +# The dashboard and documentation favicons + +The desktop app now renders its icon and its menu bar image from one traced vector. The two +favicons the product serves are still hand-made rasters with no source, and one of them is broken +in a way the file itself does not show. + +`docs-site/public/favicon.png` is the dark variant of the mark: white on transparency, 192px, +36225 of 36864 pixels carrying some alpha but every one of them RGB `(255,255,255)`, exactly one +fully opaque pixel, corner `(255,255,255,3)`. Composited on white it is a white square. A browser +tab strip is light by default and Starlight names the favicon unconditionally, so the documentation +site effectively has no favicon in light mode. `favicon.ico` beside it carries the same artwork at +16, 32 and 48, also with no fully opaque pixel. + +`gui/public/favicon.png` is the light composition and looks right at 128px, but it is a bitmap no +source can regenerate, and it is the shaded artwork rather than the flat mark: its engraved prompt +all but disappears at 16 and 32. Rendering the vector at 128 differs from it in 68% of pixels. That +is a visible simplification, not only a deduplication, and it is the same trade the app icon made. + +This unit covers the favicons and nothing else. Starlight's `logo-light.png` and `logo-dark.png` +stay independent 512px rasters — they are the brand artwork the vector was traced from, not +derived assets. `og.png` also stays, and carries a separate pre-existing defect worth its own +scope: `docs-site/astro.config.mjs` declares it 1200x630 while the committed file is 1536x1024. + +## Shape of the change + +- `scripts/lib/icon-render.ts` — new. The renderer, the RGBA re-encode and the ICO packer move here + out of `desktop/scripts/generate-icons.ts`, which keeps its size tables and imports them. Two + generators sharing one renderer is the point; a second copy of the PNG re-encode would be a + second place for the alpha bug to come back. +- `scripts/brand-favicons.ts` — new. Renders `desktop/src-tauri/icons/icon.svg` into + `gui/public/favicon.png` (128), `docs-site/public/favicon.png` (192) and + `docs-site/public/favicon.ico` (16, 32, 48) — the names, sizes and formats the two sites already + reference, so no page or config changes. `--check` regenerates into scratch and compares bytes, + the same contract the desktop set has. +- `package.json` — `favicons` and `favicons:check`. +- `tests/ci-workflows/brand-favicons.test.ts` — new, registered in `scripts/test-layout/layout.json` + and `tests/fixtures/test-layout-expected.json`. Asserts the declared sizes match what the two + sites ask for, that each committed favicon is that size with its alpha channel intact, that the + ICO carries exactly the declared sizes as embedded PNGs, and that both favicons read on a light + tab. + + That last one is why the test decodes pixels. An opaque corner alone is not enough: a plain white + square has an opaque corner and is still invisible. So it requires an opaque light corner **and** + at least 10% of the image to be opaque pixels whose luminance differs from that corner by more + than 64 — the mark actually being there. + +## Acceptance + +1. `bun run favicons:check` reports every favicon matching the source. +2. The new test fails on both ways of being invisible, checked by applying each: the + white-on-transparent artwork this replaces gives 3 pass / 1 fail, and a solid `#fcfcfc` square + gives 3 pass / 1 fail. The generated favicons give 4 pass / 0 fail. +3. `bun run privacy:scan`, `bun run structure:check` and the two test-layout guards stay green. + diff --git a/docs-site/public/favicon.ico b/docs-site/public/favicon.ico index de1689b20be73a948d7e88a269202eb0e9a2e49b..8f120715e2d30d784c5edc8e42ec5b1272931e13 100644 GIT binary patch delta 2537 zcmVZ00031AON5N001@s001B$00031AOKAR007nj001yB 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 delta 3375 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 FV1gk%STFzp diff --git a/docs-site/public/favicon.png b/docs-site/public/favicon.png index 7a741511cff9b9a19f07b1288f528a858584946d..6c26bfafb00b611d0c884e90876e43de7530e7a2 100644 GIT binary patch literal 5407 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 sfG{Q?I1J1LW%?i%)=>lD1;4ymi?B0iHWT{r~^~ literal 16096 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 diff --git a/package.json b/package.json index b8671c59b0e..cfabb4378af 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "typecheck": "bun x tsc --noEmit", "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", "privacy:scan": "bun scripts/privacy-scan.ts", + "favicons": "bun scripts/brand-favicons.ts", + "favicons:check": "bun scripts/brand-favicons.ts --check", "skill:surface": "bun scripts/generate-ocx-skill-surface.ts", "skill:surface:check": "bun scripts/generate-ocx-skill-surface.ts --check", "structure:index": "bun scripts/structure-ssot.ts --fix", diff --git a/scripts/brand-favicons.ts b/scripts/brand-favicons.ts new file mode 100644 index 00000000000..043cbb3222e --- /dev/null +++ b/scripts/brand-favicons.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun +/** + * Render the favicons the dashboard and the documentation site serve from the app icon vector. + * + * They used to be hand-made bitmaps with no source, and one of them was wrong in a way the file + * itself does not show: docs-site/public/favicon.png was the dark variant of the mark, white on + * transparency, 98.3% covered. A browser tab strip is light by default, so the documentation + * site's favicon was white on white. The .ico beside it carried the same artwork at 16, 32 and 48. + * + * Sizes and names here are the ones gui/index.html and docs-site/astro.config.mjs already + * reference, so nothing on either site has to change to pick these up. + * + * --check regenerates into a temporary directory and compares byte for byte, the same contract + * desktop/scripts/generate-icons.ts has for the app icon set. + */ +import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildIco, render } from "./lib/icon-render"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const source = join(repoRoot, "desktop", "src-tauri", "icons", "icon.svg"); + +/** Favicon PNGs the two sites reference, by path relative to the repository root. */ +const FAVICON_SIZES: Record = { + "gui/public/favicon.png": 128, + "docs-site/public/favicon.png": 192, +}; + +/** The documentation site's .ico, which Starlight names directly, and the sizes it carries. */ +const ICO_OUTPUT = "docs-site/public/favicon.ico"; +const ICO_SIZES = [16, 32, 48]; + +/** Scratch files are flat, so a nested output name becomes one path segment. */ +function scratchName(output: string): string { + return output.replaceAll("/", "__"); +} + +function generateInto(target: string): string[] { + const produced: string[] = []; + for (const [output, size] of Object.entries(FAVICON_SIZES)) { + render(size, join(target, scratchName(output)), source); + produced.push(output); + } + + const parts: Array<{ size: number; bytes: Buffer }> = []; + for (const size of ICO_SIZES) { + const scratch = join(target, `.ico-${size}.png`); + render(size, scratch, source); + parts.push({ size, bytes: readFileSync(scratch) }); + rmSync(scratch, { force: true }); + } + writeFileSync(join(target, scratchName(ICO_OUTPUT)), buildIco(parts)); + produced.push(ICO_OUTPUT); + + return produced; +} + +function main(): number { + if (!existsSync(source)) { + console.error(`[favicons] missing source: ${source}`); + return 1; + } + const check = process.argv.includes("--check"); + const scratch = mkdtempSync(join(tmpdir(), "ocx-favicons-")); + try { + const produced = generateInto(scratch); + if (!check) { + // Written in one pass at the end, so a renderer failure part way through cannot leave one + // site on the new mark and the other on the old one. + for (const output of produced) { + writeFileSync(join(repoRoot, output), readFileSync(join(scratch, scratchName(output)))); + } + console.log(`[favicons] regenerated ${produced.length} favicons from ${basename(source)}`); + return 0; + } + + const drifted = produced.filter(output => { + const committed = join(repoRoot, output); + if (!existsSync(committed)) return true; + return !readFileSync(join(scratch, scratchName(output))).equals(readFileSync(committed)); + }); + if (drifted.length > 0) { + console.error(`[favicons] these do not match the app icon vector: ${drifted.join(", ")}`); + console.error("[favicons] regenerate with: bun run favicons"); + return 1; + } + console.log(`[favicons] ${produced.length} favicons match the source`); + return 0; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +process.exit(main()); + diff --git a/scripts/lib/icon-render.ts b/scripts/lib/icon-render.ts new file mode 100644 index 00000000000..89ab2783add --- /dev/null +++ b/scripts/lib/icon-render.ts @@ -0,0 +1,162 @@ +/** + * Rendering and packing shared by the icon generators. + * + * Two generators read the same vector: desktop/scripts/generate-icons.ts builds the app icon set + * and the menu bar image, and scripts/brand-favicons.ts builds the favicons the dashboard and the + * documentation site serve. They shared nothing at first, and the PNG re-encode below is exactly + * the kind of detail that would have been copied and then fixed in one place only. + */ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { deflateSync, inflateSync } from "node:zlib"; + +export function render(size: number, out: string, from: string): void { + const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), from, "-o", out]); + if (result.status !== 0) { + const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; + throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); + } + writeFileSync(out, toRgba(readFileSync(out))); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(bytes: Buffer): number { + let c = -1; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8); + return (c ^ -1) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, "latin1"); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** Undo one PNG scanline filter in place, given the already reconstructed row above. */ +function unfilter(kind: number, row: Buffer, prior: Buffer, stride: number): void { + for (let i = 0; i < row.length; i += 1) { + const a = i >= stride ? row[i - stride]! : 0; + const b = prior[i]!; + const c = i >= stride ? prior[i - stride]! : 0; + let add = 0; + if (kind === 1) add = a; + else if (kind === 2) add = b; + else if (kind === 3) add = (a + b) >> 1; + else if (kind === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } else if (kind !== 0) throw new Error(`unknown PNG filter ${kind}`); + row[i] = (row[i]! + add) & 0xff; + } +} + +/** + * Re-encode an 8-bit truecolour PNG as truecolour-with-alpha, and leave one that already has + * alpha untouched. + * + * This exists because the icon has an opaque backdrop. librsvg notices that nothing in the render + * is transparent and drops the alpha channel, which is a valid PNG and a broken icon: Tauri's + * `generate_context!` rejects a window icon that is not RGBA, so `bun run build` fails at + * compile time with "icon ... is not RGBA". Leaving that to the renderer would also make the + * committed bytes depend on which librsvg drew them. + */ +export function toRgba(bytes: Buffer): Buffer { + if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("rsvg-convert did not emit a PNG"); + + let header: { width: number; height: number } | null = null; + let colourType = -1; + const idat: Buffer[] = []; + for (let at = 8; at + 8 <= bytes.length; ) { + const length = bytes.readUInt32BE(at); + const type = bytes.subarray(at + 4, at + 8).toString("latin1"); + const data = bytes.subarray(at + 8, at + 8 + length); + if (type === "IHDR") { + colourType = data[9]!; + if (data[8] !== 8 || data[12] !== 0) throw new Error("PNG is not 8-bit non-interlaced"); + header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4) }; + } else if (type === "IDAT") idat.push(Buffer.from(data)); + else if (type === "IEND") break; + at += 12 + length; + } + if (colourType === 6) return bytes; + if (colourType !== 2 || header === null) throw new Error(`unexpected PNG colour type ${colourType}`); + + const { width, height } = header; + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 3; + const out = Buffer.alloc(height * (1 + width * 4)); + let prior = Buffer.alloc(stride); + for (let y = 0; y < height; y += 1) { + const start = y * (1 + stride); + const row = Buffer.from(raw.subarray(start + 1, start + 1 + stride)); + unfilter(raw[start]!, row, prior, 3); + const target = y * (1 + width * 4); + out[target] = 0; + for (let x = 0; x < width; x += 1) { + row.copy(out, target + 1 + x * 4, x * 3, x * 3 + 3); + out[target + 1 + x * 4 + 3] = 0xff; + } + prior = row; + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(out, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +/** + * Pack PNGs into an ICO. + * + * Written here rather than shelled out because the alternative is ImageMagick, and adding a + * system dependency to regenerate an icon is a worse trade than 30 lines of a container format + * that has not changed in decades. A 256px entry records its dimension as 0, which is how ICO + * spells "256". + */ +export function buildIco(entries: Array<{ size: number; bytes: Buffer }>): Buffer { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type: icon + header.writeUInt16LE(entries.length, 4); + + const directory = Buffer.alloc(16 * entries.length); + let offset = header.length + directory.length; + entries.forEach((entry, index) => { + const at = index * 16; + directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at); + directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at + 1); + directory.writeUInt8(0, at + 2); // palette colours + directory.writeUInt8(0, at + 3); // reserved + directory.writeUInt16LE(1, at + 4); // colour planes + directory.writeUInt16LE(32, at + 6); // bits per pixel + directory.writeUInt32LE(entry.bytes.length, at + 8); + directory.writeUInt32LE(offset, at + 12); + offset += entry.bytes.length; + }); + + return Buffer.concat([header, directory, ...entries.map(entry => entry.bytes)]); +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1e5d3154b1c..b0a1b3b81ea 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -303,6 +303,7 @@ "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", + "brand-favicons.test.ts": "ci-workflows", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", diff --git a/tests/ci-workflows/brand-favicons.test.ts b/tests/ci-workflows/brand-favicons.test.ts new file mode 100644 index 00000000000..b782975da96 --- /dev/null +++ b/tests/ci-workflows/brand-favicons.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { inflateSync } from "node:zlib"; +import { join } from "node:path"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The favicons the dashboard and the documentation site serve are generated from the app icon + * vector by scripts/brand-favicons.ts. That script's --check compares byte for byte and needs + * rsvg-convert, which CI does not have, so what is asserted here is the shape of the committed + * set: the sizes the sites reference, the alpha channel, and the one property the old artwork + * failed. + * + * That property is worth naming. The documentation favicon used to be the dark variant of the + * mark — white on transparency — so on a light browser tab strip it was white on white and the + * site effectively had no favicon. Nothing about the file looks wrong until it is composited. + */ +const GENERATOR = repoPath("scripts/brand-favicons.ts"); + +function generatorSource(): string { + return readFileSync(GENERATOR, "utf8"); +} + +function block(source: string, opening: string, closing: string): string { + const start = source.indexOf(opening); + expect(start, `${opening} is missing from brand-favicons.ts`).toBeGreaterThan(-1); + const end = source.indexOf(closing, start + opening.length); + expect(end, `${opening} is not terminated in brand-favicons.ts`).toBeGreaterThan(-1); + return source.slice(start + opening.length, end); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** Truecolour with alpha. */ +const RGBA = 6; + +function ihdr(bytes: Buffer): { width: number; height: number; colourType: number } { + expect(bytes.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true); + expect(bytes.subarray(12, 16).toString("latin1")).toBe("IHDR"); + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), colourType: bytes[25]! }; +} + +/** Decode an 8-bit RGBA PNG into one flat pixel buffer. */ +function decode(bytes: Buffer): { width: number; height: number; pixels: Buffer } { + const { width, height, colourType } = ihdr(bytes); + expect(colourType).toBe(RGBA); + const idat: Buffer[] = []; + for (let at = 8; at + 8 <= bytes.length; ) { + const length = bytes.readUInt32BE(at); + const type = bytes.subarray(at + 4, at + 8).toString("latin1"); + if (type === "IDAT") idat.push(Buffer.from(bytes.subarray(at + 8, at + 8 + length))); + if (type === "IEND") break; + at += 12 + length; + } + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 4; + const pixels = Buffer.alloc(height * stride); + let prior = Buffer.alloc(stride); + for (let y = 0; y < height; y += 1) { + const start = y * (1 + stride); + const filter = raw[start]!; + const row = Buffer.from(raw.subarray(start + 1, start + 1 + stride)); + for (let i = 0; i < stride; i += 1) { + const a = i >= 4 ? row[i - 4]! : 0; + const b = prior[i]!; + const c = i >= 4 ? prior[i - 4]! : 0; + let add = 0; + if (filter === 1) add = a; + else if (filter === 2) add = b; + else if (filter === 3) add = (a + b) >> 1; + else if (filter === 4) { + const guess = a + b - c; + const pa = Math.abs(guess - a); + const pb = Math.abs(guess - b); + const pc = Math.abs(guess - c); + add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + row[i] = (row[i]! + add) & 0xff; + } + row.copy(pixels, y * stride); + prior = row; + } + return { width, height, pixels }; +} + +/** Rec. 709 luminance of the pixel starting at ''at''. */ +function luminance(pixels: Buffer, at: number): number { + return 0.2126 * pixels[at]! + 0.7152 * pixels[at + 1]! + 0.0722 * pixels[at + 2]!; +} + +describe("brand favicons", () => { + const source = generatorSource(); + const pngs = new Map( + [...block(source, "const FAVICON_SIZES: Record = {", "};") + .matchAll(/"([^"]+)":\s*(\d+)/g)].map(m => [m[1]!, Number(m[2])]), + ); + const icoOutput = /const ICO_OUTPUT = "([^"]+)";/.exec(source)?.[1]; + const icoSizes = block(source, "const ICO_SIZES = [", "]") + .split(",") + .map(part => Number(part.trim())) + .filter(n => Number.isFinite(n)); + + test("the generator still declares the favicons the sites reference", () => { + expect(pngs.size).toBeGreaterThan(1); + expect(icoOutput).toBeTruthy(); + expect(icoSizes.length).toBeGreaterThan(2); + + // The sizes are not free choices: these are the numbers the two sites already ask for. + expect(readFileSync(repoPath("gui/index.html"), "utf8")).toContain('href="/favicon.png"'); + const astro = readFileSync(repoPath("docs-site/astro.config.mjs"), "utf8"); + expect(astro).toContain('favicon: "/favicon.ico"'); + expect(astro).toContain('sizes: "192x192"'); + expect(pngs.get("docs-site/public/favicon.png")).toBe(192); + }); + + test("every declared favicon is committed at its declared size with alpha intact", () => { + const wrong: string[] = []; + for (const [output, size] of pngs) { + const { width, height, colourType } = ihdr(readFileSync(repoPath(output))); + if (width !== size || height !== size) wrong.push(`${output}: ${width}x${height} != ${size}`); + if (colourType !== RGBA) wrong.push(`${output}: colour type ${colourType}`); + } + expect(wrong).toEqual([]); + }); + + test("the documentation .ico carries exactly the declared sizes as PNGs", () => { + const ico = readFileSync(repoPath(icoOutput!)); + expect(ico.readUInt16LE(0)).toBe(0); + expect(ico.readUInt16LE(2)).toBe(1); + expect(ico.readUInt16LE(4)).toBe(icoSizes.length); + const seen: number[] = []; + for (let i = 0; i < icoSizes.length; i += 1) { + const entry = 6 + i * 16; + const length = ico.readUInt32LE(entry + 8); + const offset = ico.readUInt32LE(entry + 12); + expect(offset + length).toBeLessThanOrEqual(ico.length); + const { width, height } = ihdr(ico.subarray(offset, offset + length)); + expect(width).toBe(height); + expect(ico[entry]).toBe(width % 256); + seen.push(width); + } + expect(seen).toEqual(icoSizes); + }); + + /** + * The regression the generator exists for. Both favicons are now drawn on the opaque backdrop + * the app icon uses, so they composite against a light tab strip instead of disappearing into + * it. The documentation artwork this replaces was white on transparency with a corner alpha of + * 3 and no fully opaque pixel anywhere. + */ + test("both favicons show on a light tab instead of vanishing into it", () => { + const failures: string[] = []; + for (const output of pngs.keys()) { + const { width, height, pixels } = decode(readFileSync(repoPath(output))); + + // An opaque light corner is the backdrop rather than transparency. + const corner = luminance(pixels, 0); + if (pixels[3] !== 255) failures.push(output + ": corner alpha " + pixels[3]); + if (corner < 200) failures.push(output + ": corner luminance " + corner.toFixed(0)); + + // A backdrop alone is not a favicon. An opaque white square passes the check above and is + // still invisible, so the mark has to actually be there: count the pixels that survive + // compositing and differ from the backdrop enough to read at tab size. + let ink = 0; + for (let at = 0; at < pixels.length; at += 4) { + if (pixels[at + 3]! < 128) continue; + if (Math.abs(luminance(pixels, at) - corner) > 64) ink += 1; + } + const coverage = ink / (width * height); + if (coverage < 0.1) failures.push(output + ": only " + (coverage * 100).toFixed(1) + "% ink"); + } + expect(failures, "these favicons vanish on a light tab strip").toEqual([]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d0ff792be6d..d5413609fb8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -134,6 +134,7 @@ "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", + "brand-favicons.test.ts": "ci-workflows", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", From a499746395de97427182ac03917c248017094d1d Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 02:42:09 +0900 Subject: [PATCH 47/87] docs(devlog): close the brand icon unit with its on-screen and CI record (#5364) The app icon, the menu bar mark and the two favicons all landed on dev. The unit moves to _fin with what was actually observed: Finder and the Dock showing the mark on the system rounded rectangle, the menu bar status item rendering the template with both prompt glyphs as holes, and the two favicons read in a real browser tab rather than in a composite. The two screenshots that back those claims are committed alongside. Exact-head CI at 917d690ecb is green. The first attempt failed one job, macos 1/2, on a memory-watchdog test that makes eight full /api/system/memory route calls and blew its own 20s timeout at 47.4s on a loaded runner. It passed at 64b0eca2b0, which already carried both icon commits, and failed at 07e2ac9b41 before any of this landed; a focused local run finishes in 377ms. Re-running the job at the same SHA turned it and the aggregate ci check green. The flake is recorded rather than papered over: the fix is to stop that test paying for eight route snapshots. Also corrects the icon check command in the plan, which is a desktop package script and does not run from the repository root. --- .../app-icon-finder-menubar.png | Bin 0 -> 110337 bytes assets/pr-screenshots/favicon-browser-tab.png | Bin 0 -> 31596 bytes .../000_plan.md | 4 +- .../010_favicons.md | 0 .../020_closure.md | 66 ++++++++++++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 assets/pr-screenshots/app-icon-finder-menubar.png create mode 100644 assets/pr-screenshots/favicon-browser-tab.png rename devlog/{_plan => _fin}/260921_brand_icon_and_menu_bar_mark/000_plan.md (95%) rename devlog/{_plan => _fin}/260921_brand_icon_and_menu_bar_mark/010_favicons.md (100%) create mode 100644 devlog/_fin/260921_brand_icon_and_menu_bar_mark/020_closure.md diff --git a/assets/pr-screenshots/app-icon-finder-menubar.png b/assets/pr-screenshots/app-icon-finder-menubar.png new file mode 100644 index 0000000000000000000000000000000000000000..794afb137d49473abdf0de8511fb2cd283ea6ab0 GIT binary patch literal 110337 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

a5+B3mY;oj}#g}mg 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 F{|{Co35@^% literal 0 HcmV?d00001 diff --git a/assets/pr-screenshots/favicon-browser-tab.png b/assets/pr-screenshots/favicon-browser-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..3642c0afe1598ca26f6d805345f579f012e35407 GIT binary patch literal 31596 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 Date: Mon, 21 Sep 2026 07:10:41 +0900 Subject: [PATCH 48/87] docs(devlog): open the cross-path contract unit for the next release (#5371) --- .../260921_cross_path_contract/000_plan.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 devlog/_plan/260921_cross_path_contract/000_plan.md diff --git a/devlog/_plan/260921_cross_path_contract/000_plan.md b/devlog/_plan/260921_cross_path_contract/000_plan.md new file mode 100644 index 00000000000..c9ab2d7f078 --- /dev/null +++ b/devlog/_plan/260921_cross_path_contract/000_plan.md @@ -0,0 +1,113 @@ +# Cross-path contract gaps before the next release + +Status: open. Target branch for every lane: `dev`. + +The batch that landed on 2026-09-20 fixed several defects one path at a time. The +audit that followed found the same shape repeating: a policy is correct where it +was written and absent one wrapper away, or a guard that protects a real hazard +also refuses the supported case. This unit closes that class before the release +rather than adding features. + +Each lane is one branch, ordered commits, and one pull request to `dev`. No +stacked child pull requests, no native stacks. A lane owns its files; where two +lanes touch the same subsystem the split is written below so the merge is a union +and not a conflict. + +## L1 — one replay refusal on all three HTTP surfaces + +`src/lib/upstream-retry.ts` answers an ambiguous connection loss with +`upstream_reset_replay_refused` and no `Retry-After`, which means "this may +already have executed, do not send it again". `src/server/chat-native.ts` and +`src/server/responses/passthrough-error.ts` recognise that. The translated Chat +wrapper in `src/server/chat-completions.ts` does not: it preserves only the cyber +policy code and `model_not_found`, assigns `upstreamCode` just when +`classifyError` produced no code, and then adds a default `Retry-After: 2`. A +refusal to replay leaves the proxy as an ordinary rate limit that clients retry. + +Carry the replay verdict as a property of the result the three wrappers share, so +no wrapper re-derives it from a status code. Then decide the status deliberately: +the widely used Python SDK retries 429 by default, so preserving the code while +dropping `Retry-After` does not by itself stop a resend. The acceptance evidence +is the number of physical upstream sends observed through a client with retries +enabled, not a single `fetch`. + +## L2 — the Chat translation inbound loses developer position + +Outbound keeps a `developer` message where the conversation put it +(`src/adapters/openai-chat/messages.ts`). Inbound does not: +`src/chat/inbound.ts` routes both `system` and `developer` into +`systemParts` and joins them into `body.instructions`, so +`U1 → A1 → D2 → U2` becomes `instructions: D2` with `U1 → A1 → U2`. Position +is gone before any adapter sees it, and no outbound fix can restore it. + +This is not a rare internal path. Combo, policy, synthetic effort rows and several +preprocessing routes translate, so the same transcript behaves differently once a +routing feature is on. The Claude inbound already models this correctly by keeping +a mid-conversation instruction as a developer input item +(`src/claude/inbound.ts`, `src/responses/parser.ts`). Reuse that representation +for the mid-conversation case only; a leading system block keeps its current +treatment. + +## L3 — an explicit developer-role setting is ignored natively + +`foldDeveloperRoleToSystem` decides the role on the translated path. The native +Chat passthrough (`src/adapters/openai-chat/passthrough.ts`) forwards the +caller's `messages` untouched and never reads it, so an operator who recorded +"this destination rejects `developer`" still sends `developer` there. Honour the +explicit setting on both paths and leave the unset default alone: the existing +native test that preserves caller messages stays green. + +## L4 — the paginated-history transition, past "enable succeeded" + +The provider-table transition on a paginated `openai` home now completes. The +remaining risk is the state after it. Acceptance is destination preservation, not +a successful sync: existing conversations must not resume against the default +OpenAI endpoint, new conversations must use the injected provider and catalog, +restore must return operator-owned settings and remove only what this project +owns, a user-owned root override must not be taken over, and an admission-token +home must still be refused rather than reported as supported. + +## L5 — tool constraints survive response repair + +`createGrokResponsesSparseTerminalBlockRewrite` rebuilds a terminal output from +collected `output_item.done` events and receives a budget but not this request's +tool selection. The undeclared-tool guard answers a different question — whether a +name was declared — so a request with `tool_choice: none` or a narrowed allow-list +can still receive a call the repair put back. Pass the request scope into the +repair and enforce it there. Keep the failure narrow: one forbidden call must not +discard the ordinary text that accompanied it. The empty-catalog case belongs to +the same rule — compatibility is judged on the final request and the final +response, after every removal, rename and translation. + +## L6 — a client integration that writes a store nobody reads + +A newer client release reads its provider list from a different file than the one +this exporter writes, and the legacy import does not run again once the new file +exists, so an apply that reports success produces no models. Support the store the +running client actually reads, including catalog refresh and disable, or report +the write as ineffective. Deleting the new file to re-trigger a migration is not a +supported remedy. The verification unit is "the client requests the intended +provider", not "the file was written". + +## L7 — one developer-role policy in both documents and the code + +`structure/providers/chat-compat.md` states the role is forwarded as itself on +every destination; `docs-site` states an unset setting sends `system`; the two +code paths differ again. Whoever fixes this area next picks one of them and +reintroduces the regression. Make the three agree after L2 and L3 settle, and +derive the statement from the code where a test can hold it. + +## L8 — one resend budget per logical request + +The ambiguous-resend gate landed with one operator grant per request. The +composition still needs evidence: first send, reset, replacement, disconnect after +`response.created`, then the combo candidate, credential refresh and 429 legs. +Observe two separate numbers — physical sends, and sends of a turn that may already +have executed. The neighbouring retry issues are not closed by this lane and stay +open with their remaining scope recorded. + +## Out of scope + +A lenient finish for a text-only stream with no terminal event is existing +compatibility behaviour with its own regression coverage. Turning every EOF into an +error would be a policy change, not a fix, and is not part of this unit. From 6e0e912ebd15951c8e8584ebb9f76ffa001267b1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 07:33:46 +0900 Subject: [PATCH 49/87] fix(chat): honour a recorded developer-role setting on the native Chat route (#5372) * fix(chat): honour a recorded developer-role setting on the native Chat route The native Chat passthrough forwarded the caller's messages verbatim and never read `foldDeveloperRoleToSystem`, so an operator who had recorded that a destination rejects the `developer` role still sent `developer` there and the turn failed upstream with `400 role 'developer' is not allowed` before the model saw the request. The translated adapter read the same key and converted the role in place, so the two halves of one Chat route disagreed about the same destination. Move the role decision into `src/adapters/openai-chat/developer-role.ts` and read it from both paths. The helper returns `undefined` for the unrecorded state rather than a role, which keeps what an operator recorded separate from the default each route applies to silence: the translated route still folds an unrecorded destination to `system`, and the native route still forwards the caller's messages verbatim, returning the same array reference when nothing is recorded or when the recorded role is the one already on the message. The conversion rewrites the role and nothing else. Message order, the other fields of a converted message, and every other message are untouched, so where a message sits stays a separate decision from what role it carries. * test(chat): pin the native developer role through the real Chat endpoint The regression is in the body the native route builds, so these cases drive `/v1/chat/completions` against a recording upstream and assert the messages that upstream received, rather than calling the passthrough builder directly. All three states are covered: unrecorded forwards the caller's conversation verbatim, `true` converts the role in the slot the instruction arrived in while its sibling field and neighbouring turns stay identical, and `false` forwards `developer`. A fourth case holds the instruction's position across all three, so a later change cannot buy a role by moving the message. The file is a sibling of the existing endpoint coverage rather than an addition to it, and is registered in both the layout map and its expected fixture. --- scripts/test-layout/layout.json | 1 + src/adapters/openai-chat/developer-role.ts | 61 +++++++ src/adapters/openai-chat/messages.ts | 18 +- src/adapters/openai-chat/passthrough.ts | 12 +- tests/fixtures/test-layout-expected.json | 1 + .../chat-native-developer-role.test.ts | 161 ++++++++++++++++++ 6 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 src/adapters/openai-chat/developer-role.ts create mode 100644 tests/responses/chat-native-developer-role.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b0a1b3b81ea..bce5608fbd6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -337,6 +337,7 @@ "chat-inbound-reasoning-none.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", + "chat-native-developer-role.test.ts": "responses", "chat-native-image-normalization.test.ts": "responses", "chat-refusal.test.ts": "responses", "chat-refusal-scope.test.ts": "responses", diff --git a/src/adapters/openai-chat/developer-role.ts b/src/adapters/openai-chat/developer-role.ts new file mode 100644 index 00000000000..3b2481b166a --- /dev/null +++ b/src/adapters/openai-chat/developer-role.ts @@ -0,0 +1,61 @@ +import type { OcxProviderConfig } from "../../types"; + +/** + * The role a `developer` message carries on the Chat wire, decided in one place for both the + * translated adapter and the native passthrough. + * + * `developer` belongs to the Chat Completions role set, but not every OpenAI-compatible gateway + * accepts it: one that does not answers `400 role 'developer' is not allowed` and the turn never + * starts. #5213 removed a hostname test that decided the role, which was right — a gateway + * proxying OpenAI accepts the role and the hostname cannot say so. + * + * `foldDeveloperRoleToSystem` is tri-state and only two of its states say anything about the + * destination: `true` records an upstream known to reject the role, `false` one known to accept + * it, and absent means nobody has recorded either. Returning `undefined` for the absent state + * instead of a role keeps the recorded fact separate from the default a route applies to + * silence, which is what lets the native route honour the first without inheriting the second. + * + * This decides the role and only the role. Which slot the message occupies is the caller's + * decision and must not change with this value. + */ +export function explicitChatDeveloperWireRole( + provider: OcxProviderConfig, +): "developer" | "system" | undefined { + if (provider.foldDeveloperRoleToSystem === undefined) return undefined; + return provider.foldDeveloperRoleToSystem ? "system" : "developer"; +} + +/** + * The translated route's role: the recorded one, or `system` when nothing is recorded. + * + * The unrecorded state folds because the two mistakes are not symmetrical. Sending `developer` + * to a destination that rejects it fails the request outside this repository, where no test here + * can reach it; sending `system` to one that would have accepted `developer` costs the role name + * and nothing else. + */ +export function translatedChatDeveloperWireRole(provider: OcxProviderConfig): "developer" | "system" { + return explicitChatDeveloperWireRole(provider) ?? "system"; +} + +/** + * Apply a recorded role to a caller-supplied `messages` array, leaving every other field of + * every message, and the order of all of them, exactly as they arrived. + * + * The native route forwards the caller's messages verbatim, so an operator who recorded that a + * destination rejects the role still sent `developer` there and the turn failed upstream with a + * 400. Only an explicit record changes anything here: with the key unset, or set to the role the + * message already carries, the same array reference is returned and the wire is byte-identical + * to the one the caller sent. + */ +export function applyExplicitChatDeveloperRole(messages: unknown, provider: OcxProviderConfig): unknown { + const role = explicitChatDeveloperWireRole(provider); + if (role === undefined || role === "developer" || !Array.isArray(messages)) return messages; + let rewritten = false; + const applied = messages.map(message => { + if (typeof message !== "object" || message === null || Array.isArray(message)) return message; + if ((message as { role?: unknown }).role !== "developer") return message; + rewritten = true; + return { ...(message as Record), role }; + }); + return rewritten ? applied : messages; +} diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index a314ec01c82..9ad0dec86f8 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -1,5 +1,6 @@ import { isNativeOpenAIChatTarget, stripBracketedModelSuffix } from "./wire"; import { reasoningDetailSegmentForWire } from "./response-events"; +import { translatedChatDeveloperWireRole } from "./developer-role"; import { isVolcengineArkPaygChatTarget } from "./tool-schema"; import { contentPartsToText } from "../image"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation"; @@ -114,19 +115,10 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv }; const nativeOpenAI = isNativeOpenAIChatTarget(provider); - // `developer` is part of the Chat Completions role set, but not every OpenAI-compatible - // gateway accepts it: one that does not answers `400 role 'developer' is not allowed` and the - // turn never starts. #5213 removed a hostname test that decided the role, which was right — - // a gateway proxying OpenAI accepts the role and the hostname cannot say so. Defaulting to - // forwarding instead was wrong in the other direction: it assumed every destination accepts a - // role until an operator marks it, so a gateway that rejects it broke on the next request and - // no test in this repository could see it, because what breaks lives outside the repository. - // - // The key is tri-state and the unset state is the safe one. Absent means nobody has recorded - // what this destination accepts, so the role folds to `system`; `true` means it is known to - // reject the role; `false` means it is known to accept it and the role is forwarded. Either - // way the message keeps the slot it arrived in — only the role changes, never the position. - const developerWireRole = provider.foldDeveloperRoleToSystem === false ? "developer" : "system"; + // Which role a developer message carries, and why the unrecorded state folds, is stated once + // in ./developer-role.ts and read from there by the native passthrough as well. Either way the + // message keeps the slot it arrived in — only the role changes, never the position. + const developerWireRole = translatedChatDeveloperWireRole(provider); // A developer message keeps the slot it arrived in. Hoisting its text into the leading // system block moved a mid-conversation instruction ahead of every turn it was written to // follow, and the caller saw an ordinary answer either way (#5213). The Claude inbound mints diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index e653017bbcc..2f13ebd8990 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -1,6 +1,7 @@ import { openAIChatTransport, stripBracketedModelSuffix } from "./wire"; import type { AdapterRequest } from "../base"; import { frameAgentRouterMessages } from "../agentrouter"; +import { applyExplicitChatDeveloperRole } from "./developer-role"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../../providers/openrouter-routing"; import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../../providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../../providers/service-tier"; @@ -55,7 +56,16 @@ export function buildOpenAIChatPassthroughRequest( const body: Record = { model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId, - messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages), + // The caller's messages are forwarded as they arrived, with one exception: an operator who + // recorded that this destination rejects the `developer` role gets that role converted in + // place. Verbatim was not neutral there — it sent the role anyway and the turn failed + // upstream with a 400 before the model saw it. An unrecorded destination is still verbatim, + // and the conversion changes the role of those messages and nothing else, so the position + // of every message and every other field survive unchanged. + messages: applyExplicitChatDeveloperRole( + frameAgentRouterMessages(provider.baseUrl, rawBody.messages), + provider, + ), stream, }; for (const field of CHAT_PASSTHROUGH_FIELDS) { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d5413609fb8..9299cbb92a5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -168,6 +168,7 @@ "chat-inbound-reasoning-none.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", + "chat-native-developer-role.test.ts": "responses", "chat-native-image-normalization.test.ts": "responses", "chat-refusal.test.ts": "responses", "chat-refusal-scope.test.ts": "responses", diff --git a/tests/responses/chat-native-developer-role.test.ts b/tests/responses/chat-native-developer-role.test.ts new file mode 100644 index 00000000000..364b74b5ee1 --- /dev/null +++ b/tests/responses/chat-native-developer-role.test.ts @@ -0,0 +1,161 @@ +/** + * The native Chat route forwards the caller's messages to the upstream without translating + * them, which also meant it never read `foldDeveloperRoleToSystem`. An operator who had + * recorded that a destination rejects the `developer` role still sent `developer` there, and + * the destination answered `400 role 'developer' is not allowed` before the model saw the + * request — a failure that lives outside this repository, which is why nothing here caught it. + * + * These cases drive the real `/v1/chat/completions` endpoint and read the body the upstream + * actually received, because the defect was in which body the route builds, not in what the + * adapter returns when it is called directly. The translated route's own role decision is + * covered beside that adapter in `tests/adapters/openai/openai-chat-developer-position.test.ts`. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-chat-developer-role-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-chat-developer-role-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +/** A mid-conversation instruction, with a sibling field and turns on both sides of it. */ +const CONVERSATION = [ + { role: "system", content: "base instructions" }, + { role: "user", content: "First turn." }, + { role: "developer", name: "reminder-sentinel", content: "Answer in exactly one sentence." }, + { role: "user", content: "Second turn." }, +]; + +/** The instruction's index in the conversation above; it must hold whatever role it carries. */ +const INSTRUCTION_SLOT = CONVERSATION.findIndex(message => message.role === "developer"); + +/** Send one non-streaming request through the proxy and return the body the upstream received. */ +async function upstreamBodyFor( + providerOverrides: Partial, +): Promise> { + const captured: Array<{ pathname: string; body: Record }> = []; + const upstream = Bun.serve({ + port: 0, + async fetch(req) { + const { pathname } = new URL(req.url); + captured.push({ pathname, body: await req.json() as Record }); + // The translated route reaches a different endpoint, so answering every path would let a + // rerouted request look like a successful native send. + if (!pathname.endsWith("/chat/completions")) { + return Response.json({ error: { message: `unexpected path ${pathname}` } }, { status: 404 }); + } + return Response.json({ + id: "chatcmpl_developer_role", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + }, + }); + saveConfig({ + port: 0, + defaultProvider: "mock", + providers: { + mock: { + adapter: "openai-chat", + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + apiKey: "k", + allowPrivateNetwork: true, + ...providerOverrides, + }, + }, + } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: CONVERSATION }), + }); + expect(response.status).toBe(200); + await response.text(); + } finally { + await server.stop(true); + upstream.stop(true); + } + expect(captured).toHaveLength(1); + expect(captured[0]!.pathname).toBe("/v1/chat/completions"); + return captured[0]!.body; +} + +function upstreamMessages(body: Record): Array> { + return body.messages as Array>; +} + +describe("the developer role on the native Chat wire", () => { + test("an unrecorded destination still receives the caller's messages verbatim", async () => { + // The default is the one thing this change must not move: nobody has recorded what this + // destination accepts, so the route has no reason to rewrite what the caller sent. + expect(upstreamMessages(await upstreamBodyFor({}))).toEqual(CONVERSATION); + }); + + test("a destination recorded as rejecting the role receives system in the same slot", async () => { + const messages = upstreamMessages(await upstreamBodyFor({ foldDeveloperRoleToSystem: true })); + + expect(messages.map(message => message.role)).toEqual(["system", "user", "system", "user"]); + // The role changes and nothing else does: the sibling field survives, and so does the text. + expect(messages[INSTRUCTION_SLOT]).toEqual({ + ...CONVERSATION[INSTRUCTION_SLOT]!, + role: "system", + }); + // The turns this instruction was written to sit between are still on either side of it. + expect(messages.filter((_, index) => index !== INSTRUCTION_SLOT)) + .toEqual(CONVERSATION.filter((_, index) => index !== INSTRUCTION_SLOT)); + }); + + test("a destination recorded as accepting the role receives it unchanged", async () => { + const messages = upstreamMessages(await upstreamBodyFor({ foldDeveloperRoleToSystem: false })); + + expect(messages).toEqual(CONVERSATION); + expect(messages[INSTRUCTION_SLOT]!.role).toBe("developer"); + }); + + test("the instruction holds its slot in all three states", async () => { + // One request at a time: each one writes the configuration the next server reads. + const bodies = [ + await upstreamBodyFor({}), + await upstreamBodyFor({ foldDeveloperRoleToSystem: true }), + await upstreamBodyFor({ foldDeveloperRoleToSystem: false }), + ]; + + for (const body of bodies) { + const messages = upstreamMessages(body); + expect(messages).toHaveLength(CONVERSATION.length); + expect(messages[INSTRUCTION_SLOT]!.content).toBe(CONVERSATION[INSTRUCTION_SLOT]!.content); + expect(String(messages[0]!.content)).not.toContain(CONVERSATION[INSTRUCTION_SLOT]!.content); + } + }); + + test("the route remains a passthrough rather than a translation", async () => { + const body = await upstreamBodyFor({ foldDeveloperRoleToSystem: true }); + + expect(body.model).toBe("test-model"); + expect(body).not.toHaveProperty("input"); + expect(body).not.toHaveProperty("instructions"); + }); +}); From e4a5255e352a3d646b26ea608a23df7fc18e7482 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 07:37:35 +0900 Subject: [PATCH 50/87] docs(devlog): bind lane ownership, ordering and acceptance for the cross-path unit (#5379) --- .../010_lane_boundaries.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 devlog/_plan/260921_cross_path_contract/010_lane_boundaries.md diff --git a/devlog/_plan/260921_cross_path_contract/010_lane_boundaries.md b/devlog/_plan/260921_cross_path_contract/010_lane_boundaries.md new file mode 100644 index 00000000000..ae2d37c9f3d --- /dev/null +++ b/devlog/_plan/260921_cross_path_contract/010_lane_boundaries.md @@ -0,0 +1,70 @@ +# Lane ownership, ordering and acceptance shape + +The first audit round of `000_plan.md` returned blocking findings: the lanes were +described by symptom without an owned-file set, two lanes overlapped on the send +path, one lane depended on two others without saying so, and two lanes named an +acceptance unit that no test can observe. This document answers those and is the +binding half of the unit. + +## Owned files + +A lane changes files in its own row. A file in another row is read-only for it. +Anything outside every row is open, but a second lane touching it has to say so in +its pull request. + +| Lane | Owns | +|---|---| +| L1 | `src/server/chat-completions.ts` error path, `src/server/chat-native.ts` error path, `src/server/responses/passthrough-error.ts`, the replay-verdict carrier it extracts, and tests for those | +| L2 | `src/chat/inbound.ts`, `src/responses/parser.ts` where the Chat path needs it, and its own tests | +| L3 | `src/adapters/openai-chat/passthrough.ts`, `src/adapters/openai-chat/messages.ts` role selection, and its own tests | +| L4 | `src/codex/history-provider.ts`, `src/codex/inject.ts`, `tests/codex-integration/*` | +| L5 | `src/server/grok-responses-snapshot-repair.ts`, `src/server/responses-undeclared-tool-guard.ts`, `src/server/responses/passthrough-dispatch.ts` call sites, and its own tests | +| L6 | `src/clients/config-export/`, `src/integrations/registry.ts` entry for that client, and its own tests | +| L7 | `structure/providers/chat-compat.md`, `docs-site` provider reference, and the generated binding check | +| L8 | `src/lib/request-execution-budget.ts`, `src/lib/request-resend-gate.ts`, and send-count tests | + +L1 and L8 both live near the send path and are split by question. L1 owns what the +client is told when a replay is refused — code, status, retry header, and the +carrier that stops each wrapper re-deriving it. L8 owns how many sends one logical +request may make and which leg may spend the shared reserve. L8 does not change an +error body; L1 does not change an allowance. + +## Ordering + +L7 lands last. It writes down the single developer-role policy, and that policy is +not settled until L2 fixes where the message sits and L3 fixes which role it +carries. Until both are on `dev`, L7 keeps its branch rebased and its pull request +open. Every other lane is independent and merges in whatever order its evidence +arrives. + +## Acceptance that a test can hold + +Static reading is how a lane reviews itself; hosted CI on the exact head is what +decides. A lane whose acceptance sentence names something no job can observe has +to restate it: + +- L1 and L8 count sends against a recorded fetch, so the number is an assertion and + not an inference. L1 additionally asserts the response the client receives. +- L4 drives a temporary home with fixtures: enable, create, resume, restore. It + asserts the destination recorded for an existing conversation and for a new one, + and it asserts the refusal that an admission-token home still receives. The + refusal path already has coverage; the transition and the post-transition + destinations are the new part. +- L6 cannot prove what a third-party client does at runtime. Its assertion is that + the file the current client release reads carries the intended provider after + enable and refresh, and carries nothing after disable — with the client's own + published schema quoted in the pull request as the reason that file is the one + that matters. If the lane cannot establish the schema, it reports the write as + ineffective instead, which is the honest half of the original instruction. +- L5 asserts on the block rewrite directly: `tool_choice: none`, a narrowed + allow-list, an empty catalog after normalisation, and a forbidden call arriving + beside ordinary text, which must survive. +- L7 asserts that the documented default is derived from the code, so a default + change fails a check rather than only a review. + +## Baseline + +The lanes are cut from `dev` after the September 20 batch, so the paginated +transition and the per-request resend gate are already present. A lane that finds +its premise already satisfied says so in its pull request and narrows to the part +that is not, rather than reimplementing what landed. From 34332bb7858a1ae716afcbb00ce50b0a28a3e14b Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 07:39:47 +0900 Subject: [PATCH 51/87] docs: one developer-role policy in the Chat contract, the reference and the translations (#5373) * docs(structure): state the developer-role mapping the Chat adapter implements The Chat compatibility contract said a developer message was forwarded as itself on every destination, while the adapter has folded the role to system unless a destination records that it accepts it. Replace the stale claim with the tri-state mapping the wire implements, and record it as INV-CHAT-01 so the statement is bound to a test rather than to review. * docs(reference): spell out all three foldDeveloperRoleToSystem states Each configuration reference described the setting correctly but only in prose. State the unset, true and false wire roles explicitly, in the same order in every locale, so the mapping can be compared against the adapter instead of read for intent. * docs(claude-code): correct the translated developer-role default Seven translated guides still said a timeline reminder is forwarded with the developer role and that foldDeveloperRoleToSystem is what converts it, which the English guide and the adapter both stopped saying. Each translation now states that a reminder is sent as system unless the provider records that the upstream accepts developer. * test(ci-workflows): derive the documented developer-role policy from the Chat wire Build the documented sentence from the role the openai-chat adapter serializes for each of the three settings states, require both English documents to carry it, and hold every translated page against the English source. A changed default now fails a check instead of leaving one document behind. --- .../src/content/docs/fr/guides/claude-code.md | 2 +- .../fr/reference/configuration/providers.md | 2 +- .../src/content/docs/ja/guides/claude-code.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/guides/claude-code.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/guides/claude-code.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../src/content/docs/tr/guides/claude-code.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../content/docs/zh-cn/guides/claude-code.md | 2 +- .../reference/configuration/providers.md | 2 +- .../content/docs/zh-tw/guides/claude-code.md | 2 +- .../reference/configuration/providers.md | 2 +- scripts/test-layout/layout.json | 3 +- structure/overview.md | 7 + structure/providers/chat-compat.md | 23 ++- .../docs-developer-role-policy.test.ts | 152 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- 20 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 tests/ci-workflows/docs-developer-role-policy.test.ts diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 268c5623261..45851d1fc0d 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -655,4 +655,4 @@ Utilisez `"haiku"` comme valeur de remplacement pour le modèle. Dans `config.json`, `claudeCode.stabilizePromptCache: true` déplace les notices Claude reconnues en fin des instructions système vers un dernier message utilisateur sur les routes traduites. La valeur par défaut est `false`. Activez cette option seulement si ce changement de rôle convient à vos clients. Les exemples dans des blocs de code et le texte non reconnu sont conservés ; le transfert Anthropic natif reste inchangé. Sans métadonnées, la clé de cache suit les instructions stabilisées. Cette option ne crée pas une identité de conversation et ne garantit aucun succès du cache amont. -Sur toutes les routes Chat traduites, les rappels de l’historique conservent leur position dans la conversation, après les résultats d’outils encore attendus, et sont transmis avec le rôle `developer`. L’ajout d’un rappel ne réécrit donc pas le prompt système initial, et une instruction placée au milieu de la conversation n’arrive plus avant les tours qu’elle était censée suivre. Si le service en amont refuse le rôle `developer`, activez `foldDeveloperRoleToSystem` sur ce fournisseur : le rappel est alors envoyé en `system`, à la même position. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; le transfert Anthropic natif reste inchangé. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. +Sur toutes les routes Chat traduites, les rappels de l’historique conservent leur position dans la conversation, après les résultats d’outils encore attendus. L’ajout d’un rappel ne réécrit donc pas le prompt système initial, et une instruction placée au milieu de la conversation n’arrive plus avant les tours qu’elle était censée suivre. Le rôle porté par cet emplacement se décide séparément : un rappel part en `system`, sauf si le fournisseur enregistre `foldDeveloperRoleToSystem: false`, ce qui indique que le service en amont accepte le rôle `developer` et le transmet à la même position. Un service qui ne l’accepte pas répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d’où le repli d’une destination non enregistrée. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; le transfert Anthropic natif reste inchangé. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 45ab2192c50..c4fd2dc57de 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -132,7 +132,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | | `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | -| `foldDeveloperRoleToSystem?` | `boolean` | Indique si une destination `openai-chat` accepte le rôle `developer`. Non défini signifie que rien n'a été enregistré pour cette destination, donc le message part en `system` ; `true` enregistre un service en amont qui refuse le rôle ; `false` en enregistre un qui l'accepte, et le rôle est transmis. Dans tous les cas le message conserve sa position dans la conversation ; seul le rôle change. Une destination qui refuse le rôle répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d'où l'état non enregistré replié par défaut. | +| `foldDeveloperRoleToSystem?` | `boolean` | Indique si une destination `openai-chat` accepte le rôle `developer`. `foldDeveloperRoleToSystem` non défini envoie `system`, `true` envoie `system` et `false` envoie `developer`. Non défini signifie que rien n'a été enregistré pour cette destination ; `true` enregistre un service en amont qui refuse le rôle ; `false` en enregistre un qui l'accepte. Dans tous les cas le message conserve sa position dans la conversation ; seul le rôle change. Une destination qui refuse le rôle répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d'où l'état non enregistré replié par défaut. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 2a15039bc91..390fbccd054 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -521,4 +521,4 @@ Anthropic バックエンドを明示すると意図的に失敗後停止しま `config.json` の `claudeCode.stabilizePromptCache` を `true` にすると、変換ルートのシステム指示末尾にある対応済み Claude 通知を最後のユーザーメッセージへ移します。既定値は `false` です。このロール変更が適切なクライアントでのみ有効にしてください。コードフェンス内の例と一致しない本文は保持され、Anthropic のネイティブ転送は変わりません。メタデータがない場合のキャッシュキーは安定化した指示から計算されます。会話 ID の生成やキャッシュヒットの保証は行いません。 -変換されたすべての Chat ルートで、タイムライン上のリマインダーは保留中のツール結果の後、会話内の元の位置を保ったまま `developer` ロールで転送されます。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わらず、会話の途中に置かれた指示がそれより前のターンの前に移動することもありません。上流が `developer` ロールを受け付けない場合は、そのプロバイダーに `foldDeveloperRoleToSystem` を設定してください。同じ位置のまま `system` として送信されます。`stabilizePromptCache` の設定にかかわらず適用され、Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 +変換されたすべての Chat ルートで、タイムライン上のリマインダーは保留中のツール結果の後、会話内の元の位置を保ちます。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わらず、会話の途中に置かれた指示がそれより前のターンの前に移動することもありません。そのスロットが運ぶロールは別に決まります。プロバイダーが `foldDeveloperRoleToSystem: false` を記録していないかぎり、リマインダーは `system` として送られます。この記録は上流が `developer` ロールを受け付けることを表し、その場合は同じ位置のまま転送します。受け付けない上流は `400 role 'developer' is not allowed` を返してターンが始まらないため、記録のない宛先は畳む側になります。`stabilizePromptCache` の設定にかかわらず適用され、Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index bac2502e0ec..7dacef8a955 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -125,7 +125,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | -| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` の宛先が `developer` ロールを受け付けるかを記録します。未設定はこの宛先について何も記録されていないことを意味し、メッセージは `system` として送られます。`true` は上流がロールを拒否する記録、`false` は受け付ける記録で、ロールはそのまま転送されます。いずれの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。ロールを拒否する宛先は `400 role 'developer' is not allowed` を返してターンが始まらないため、未記録の既定は畳む側にしてあります。 | +| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` の宛先が `developer` ロールを受け付けるかを記録します。`foldDeveloperRoleToSystem` が未設定なら `system`、`true` なら `system`、`false` なら `developer` として送ります。未設定はこの宛先について何も記録されていないことを意味し、`true` は上流がロールを拒否する記録、`false` は受け付ける記録です。いずれの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。ロールを拒否する宛先は `400 role 'developer' is not allowed` を返してターンが始まらないため、未記録の既定は畳む側にしてあります。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index ca71508d4e1..27759bbc709 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -584,4 +584,4 @@ Anthropic 백엔드를 명시하면 의도적으로 실패 후 중단해요. `config.json`에서 `claudeCode.stabilizePromptCache`를 `true`로 설정하면 번역 경로의 시스템 지시 끝에 붙은 지원 대상 Claude 알림을 마지막 사용자 메시지로 옮깁니다. 기본값은 `false`입니다. 사용하는 클라이언트에서 이 역할 변경을 허용할 때만 켜세요. 코드 펜스 안의 예제와 일치하지 않는 원문은 보존하며, Anthropic 원본 전달 경로는 바꾸지 않습니다. 메타데이터가 없는 요청의 캐시 키는 정리된 지시문을 기준으로 계산합니다. 대화 식별자를 만들거나 상위 서비스의 캐시 적중을 보장하는 기능은 아닙니다. -변환된 모든 Chat 경로에서 타임라인 알림은 대기 중인 도구 결과 뒤, 대화 안의 원래 위치를 그대로 유지하며 `developer` 역할로 전달됩니다. 덕분에 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않고, 대화 중간의 지시가 그 지시보다 앞선 턴으로 끌려가지도 않습니다. 상위 서비스가 `developer` 역할을 거부한다면 해당 공급자에 `foldDeveloperRoleToSystem`을 설정하세요. 그러면 같은 위치에서 `system`으로 보냅니다. `stabilizePromptCache` 설정과 관계없이 적용되며 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. +변환된 모든 Chat 경로에서 타임라인 알림은 대기 중인 도구 결과 뒤, 대화 안의 원래 위치를 그대로 유지합니다. 덕분에 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않고, 대화 중간의 지시가 그 지시보다 앞선 턴으로 끌려가지도 않습니다. 그 자리가 어떤 역할을 싣는지는 따로 정합니다. 공급자가 `foldDeveloperRoleToSystem: false`를 기록하지 않는 한 알림은 `system`으로 보내며, 이 기록은 상위 서비스가 `developer` 역할을 받아들인다는 뜻이라 같은 위치에서 그대로 전달합니다. 받아들이지 않는 상위 서비스는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 목적지는 접는 쪽을 씁니다. `stabilizePromptCache` 설정과 관계없이 적용되며 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 8fe624c51c9..fdbf9c697a8 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -125,7 +125,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | -| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` 목적지가 `developer` 역할을 받는지 기록합니다. 값이 없으면 이 목적지에 대해 기록된 것이 없다는 뜻이므로 메시지를 `system`으로 보냅니다. `true`는 상위 서비스가 역할을 거부한다는 기록이고, `false`는 받아들인다는 기록이라 역할을 그대로 전달합니다. 어느 경우에도 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 역할을 거부하는 목적지는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 상태의 기본값을 접는 쪽으로 둡니다. | +| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` 목적지가 `developer` 역할을 받는지 기록합니다. `foldDeveloperRoleToSystem`이 없으면 `system`, `true`이면 `system`, `false`이면 `developer`로 보냅니다. 값이 없다는 것은 이 목적지에 대해 기록된 것이 없다는 뜻이고, `true`는 상위 서비스가 역할을 거부한다는 기록, `false`는 받아들인다는 기록입니다. 어느 경우에도 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 역할을 거부하는 목적지는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 상태의 기본값을 접는 쪽으로 둡니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 3cae671a466..c7535203297 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -203,7 +203,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | -| `foldDeveloperRoleToSystem?` | `boolean` | Whether an `openai-chat` destination accepts the `developer` role. Unset means nothing has been recorded about this destination, so the message is sent as `system`; `true` records an upstream that rejects the role; `false` records one that accepts it, and the role is forwarded. The message keeps its position in the conversation in every case — only the role changes. A destination that rejects the role answers `400 role 'developer' is not allowed` and the turn never starts, which is why the unrecorded state is the folded one. | +| `foldDeveloperRoleToSystem?` | `boolean` | Whether an `openai-chat` destination accepts the `developer` role. `foldDeveloperRoleToSystem` unset sends `system`, `true` sends `system`, and `false` sends `developer`. Unset means nothing has been recorded about this destination; `true` records an upstream that rejects the role; `false` records one that accepts it. The message keeps its position in the conversation in every case — only the role changes. A destination that rejects the role answers `400 role 'developer' is not allowed` and the turn never starts, which is why the unrecorded state is the folded one. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 795a1a80d89..f82e2e007e4 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -529,4 +529,4 @@ Responses `web_search_call` в парные блоки Anthropic `server_tool_us Параметр `claudeCode.stabilizePromptCache: true` в `config.json` переносит поддерживаемые уведомления Claude в конце системных инструкций в последнее пользовательское сообщение на маршрутах с преобразованием. По умолчанию он выключен (`false`). Включайте его только когда такое изменение роли допустимо для ваших клиентов. Примеры в блоках кода и нераспознанный текст сохраняются; нативная передача Anthropic не меняется. Без метаданных ключ кэша рассчитывается по стабилизированным инструкциям. Идентификатор разговора не создаётся, попадания в кэш не гарантируются. -На всех преобразованных Chat-маршрутах напоминания в истории сохраняют свою позицию в разговоре — после ожидаемых результатов инструментов — и передаются с ролью `developer`. Поэтому добавление нового напоминания не переписывает начальный системный промпт, а инструкция из середины разговора не оказывается раньше тех ходов, после которых она была написана. Если вышестоящий сервис не принимает роль `developer`, задайте у этого провайдера `foldDeveloperRoleToSystem`: напоминание будет отправлено как `system` в той же позиции. Это работает независимо от `stabilizePromptCache`; нативная передача Anthropic остаётся прежней. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. +На всех преобразованных Chat-маршрутах напоминания в истории сохраняют свою позицию в разговоре — после ожидаемых результатов инструментов. Поэтому добавление нового напоминания не переписывает начальный системный промпт, а инструкция из середины разговора не оказывается раньше тех ходов, после которых она была написана. Роль этой позиции определяется отдельно: напоминание отправляется как `system`, если провайдер не записал `foldDeveloperRoleToSystem: false` — эта запись означает, что вышестоящий сервис принимает роль `developer`, и тогда она передаётся в той же позиции. Сервис, который её не принимает, отвечает `400 role 'developer' is not allowed`, и ход не начинается, поэтому незаписанное назначение сворачивается. Это работает независимо от `stabilizePromptCache`; нативная передача Anthropic остаётся прежней. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 6c683c8b51f..35de1cf8541 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -138,7 +138,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | -| `foldDeveloperRoleToSystem?` | `boolean` | Принимает ли назначение `openai-chat` роль `developer`. Значение не задано означает, что об этом назначении ничего не записано, поэтому сообщение отправляется как `system`; `true` фиксирует вышестоящий сервис, который роль отклоняет; `false` — тот, который её принимает, и роль передаётся как есть. В любом случае сообщение сохраняет свою позицию в разговоре, меняется только роль. Назначение, отклоняющее роль, отвечает `400 role 'developer' is not allowed`, и ход не начинается — поэтому незаписанное состояние по умолчанию свёрнуто. | +| `foldDeveloperRoleToSystem?` | `boolean` | Принимает ли назначение `openai-chat` роль `developer`. Если `foldDeveloperRoleToSystem` не задан, сообщение уходит как `system`; при `true` — как `system`; при `false` — как `developer`. Не задано означает, что об этом назначении ничего не записано; `true` фиксирует вышестоящий сервис, который роль отклоняет; `false` — тот, который её принимает. В любом случае сообщение сохраняет свою позицию в разговоре, меняется только роль. Назначение, отклоняющее роль, отвечает `400 role 'developer' is not allowed`, и ход не начинается — поэтому незаписанное состояние по умолчанию свёрнуто. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 24efef89db3..d6f5496db14 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -738,4 +738,4 @@ tutucusu olarak `"haiku"` iletin. `config.json` içindeki `claudeCode.stabilizePromptCache: true`, dönüştürülen rotalarda sistem talimatlarının sonundaki desteklenen Claude bildirimlerini son kullanıcı mesajına taşır. Varsayılan değer `false` olur. Yalnızca bu rol değişikliği istemcileriniz için uygunsa etkinleştirin. Kod bloklarındaki örnekler ve eşleşmeyen metin korunur; yerel Anthropic aktarımı değişmez. Meta veri yoksa önbellek anahtarı kararlı talimatlardan hesaplanır. Bu seçenek konuşma kimliği oluşturmaz veya üst hizmette önbellek isabeti garanti etmez. -Dönüştürülen tüm Chat rotalarında zaman çizelgesi hatırlatmaları, bekleyen araç sonuçlarından sonra konuşmadaki konumlarını korur ve `developer` rolüyle iletilir. Böylece yeni bir hatırlatma eklenmesi baştaki sistem istemini yeniden yazmaz ve konuşmanın ortasındaki bir yönerge, izlemesi gereken turların önüne geçmez. Üst hizmet `developer` rolünü kabul etmiyorsa ilgili sağlayıcıda `foldDeveloperRoleToSystem` ayarını açın; hatırlatma aynı konumda `system` olarak gönderilir. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. +Dönüştürülen tüm Chat rotalarında zaman çizelgesi hatırlatmaları, bekleyen araç sonuçlarından sonra konuşmadaki konumlarını korur. Böylece yeni bir hatırlatma eklenmesi baştaki sistem istemini yeniden yazmaz ve konuşmanın ortasındaki bir yönerge, izlemesi gereken turların önüne geçmez. O konumun hangi rolü taşıdığı ayrı bir karardır: sağlayıcı `foldDeveloperRoleToSystem: false` kaydetmedikçe hatırlatma `system` olarak gönderilir; bu kayıt, üst hizmetin `developer` rolünü kabul ettiğini belirtir ve rol aynı konumda iletilir. Kabul etmeyen bir üst hizmet `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydı olmayan hedefin katlanmasının nedeni budur. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 1028d5ee95a..1fbe43c91d8 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -139,7 +139,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | -| `foldDeveloperRoleToSystem?` | `boolean` | Bir `openai-chat` hedefinin `developer` rolünü kabul edip etmediğini kaydeder. Ayarlanmamış olması bu hedef için hiçbir şey kaydedilmediği anlamına gelir; mesaj `system` olarak gönderilir. `true` rolü reddeden bir üst hizmeti, `false` ise kabul edeni kaydeder ve rol olduğu gibi iletilir. Her durumda mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Rolü reddeden bir hedef `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydedilmemiş durumun katlanmış olmasının nedeni budur. | +| `foldDeveloperRoleToSystem?` | `boolean` | Bir `openai-chat` hedefinin `developer` rolünü kabul edip etmediğini kaydeder. `foldDeveloperRoleToSystem` ayarlanmamışsa `system`, `true` ise `system`, `false` ise `developer` olarak gönderilir. Ayarlanmamış olması bu hedef için hiçbir şey kaydedilmediği anlamına gelir; `true` rolü reddeden bir üst hizmeti, `false` ise kabul edeni kaydeder. Her durumda mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Rolü reddeden bir hedef `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydedilmemiş durumun katlanmış olmasının nedeni budur. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 6c65903fa46..746eeba39f4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -467,4 +467,4 @@ Claude 模型时自动加载。对于原生透传,这是正常现象;对于 在 `config.json` 中设置 `claudeCode.stabilizePromptCache: true`,可在转换路由上将系统指令末尾受支持的 Claude 提示移到最后一条用户消息。默认值为 `false`。仅在客户端允许这种角色变化时启用。代码围栏内的示例和不匹配的文本会保留,Anthropic 原生透传不变。没有元数据时,缓存键按稳定后的指令计算。该选项不会生成会话标识,也不保证上游缓存命中。 -在所有转换后的 Chat 路由上,时间线提醒都会保留在对话中的原有位置(排在尚待返回的工具结果之后),并以 `developer` 角色转发。因此,追加提醒不会重写开头的系统提示,对话中途的指令也不会被挪到它本应跟随的轮次之前。如果上游拒绝 `developer` 角色,请在该提供方上设置 `foldDeveloperRoleToSystem`,提醒会在同一位置以 `system` 发送。无论 `stabilizePromptCache` 是否启用,该行为都会生效;Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 +在所有转换后的 Chat 路由上,时间线提醒都会保留在对话中的原有位置(排在尚待返回的工具结果之后)。因此,追加提醒不会重写开头的系统提示,对话中途的指令也不会被挪到它本应跟随的轮次之前。该位置携带哪个角色是单独决定的:除非提供方记录了 `foldDeveloperRoleToSystem: false`,否则提醒以 `system` 发送;该记录表示上游接受 `developer` 角色,此时提醒在同一位置按原样转发。不接受该角色的上游会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,所以未记录的目的地采用折叠。无论 `stabilizePromptCache` 是否启用,该行为都会生效;Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 5e748183d92..85a12fa08ba 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -125,7 +125,7 @@ selector,而不是分配一个新名称。 | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | -| `foldDeveloperRoleToSystem?` | `boolean` | 记录某个 `openai-chat` 目的地是否接受 `developer` 角色。未设置表示尚未记录该目的地的情况,因此消息按 `system` 发送;`true` 记录上游拒绝该角色;`false` 记录其接受该角色,角色按原样转发。无论哪种情况,消息都保留在对话中的原有位置,只有角色改变。拒绝该角色的目的地会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,这就是未记录状态默认折叠的原因。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 记录某个 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未设置时按 `system` 发送,`true` 时按 `system` 发送,`false` 时按 `developer` 发送。未设置表示尚未记录该目的地的情况;`true` 记录上游拒绝该角色;`false` 记录其接受该角色。无论哪种情况,消息都保留在对话中的原有位置,只有角色改变。拒绝该角色的目的地会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,这就是未记录状态默认折叠的原因。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 51231f1a38d..fc35c52b399 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -538,4 +538,4 @@ Claude 模型時自動載入。對於原生透傳,這是正常現象;對於 在 `config.json` 中設定 `claudeCode.stabilizePromptCache: true`,可在轉換路由上將系統指令末尾支援的 Claude 提示移到最後一則使用者訊息。預設值為 `false`。僅在用戶端允許這種角色變更時啟用。程式碼圍欄中的範例和不符合的文字會保留,Anthropic 原生轉送不變。沒有中繼資料時,快取鍵依穩定後的指令計算。此選項不會產生對話識別碼,也不保證上游快取命中。 -在所有轉換後的 Chat 路由上,時間線提醒都會保留在對話中的原有位置(排在尚待傳回的工具結果之後),並以 `developer` 角色轉送。因此,新增提醒不會重寫開頭的系統提示,對話中途的指令也不會被移到它原本應跟隨的輪次之前。若上游拒絕 `developer` 角色,請在該提供者上設定 `foldDeveloperRoleToSystem`,提醒會在相同位置以 `system` 傳送。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 +在所有轉換後的 Chat 路由上,時間線提醒都會保留在對話中的原有位置(排在尚待傳回的工具結果之後)。因此,新增提醒不會重寫開頭的系統提示,對話中途的指令也不會被移到它原本應跟隨的輪次之前。該位置攜帶哪個角色是另外決定的:除非提供者記錄了 `foldDeveloperRoleToSystem: false`,否則提醒以 `system` 傳送;該記錄表示上游接受 `developer` 角色,此時提醒在相同位置照原樣轉送。不接受該角色的上游會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,所以未記錄的目的地採用摺疊。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index c10abd0d869..cdbd005d00f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -99,7 +99,7 @@ ocx models provider openrouter on | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | -| `foldDeveloperRoleToSystem?` | `boolean` | 記錄某個 `openai-chat` 目的地是否接受 `developer` 角色。未設定表示尚未記錄該目的地的情況,因此訊息以 `system` 傳送;`true` 記錄上游拒絕該角色;`false` 記錄其接受該角色,角色照原樣轉送。無論何者,訊息都保留在對話中的原有位置,只有角色改變。拒絕該角色的目的地會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,這就是未記錄狀態預設摺疊的原因。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 記錄某個 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未設定時以 `system` 傳送,`true` 時以 `system` 傳送,`false` 時以 `developer` 傳送。未設定表示尚未記錄該目的地的情況;`true` 記錄上游拒絕該角色;`false` 記錄其接受該角色。無論何者,訊息都保留在對話中的原有位置,只有角色改變。拒絕該角色的目的地會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,這就是未記錄狀態預設摺疊的原因。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bce5608fbd6..53e13ca65a3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1624,7 +1624,8 @@ "claude-intercept-integration.test.ts": "server", "management-provider-reset-replay.test.ts": "server", "responses-reset-replay.test.ts": "responses", - "ambiguous-resend-gate.test.ts": "lib" + "ambiguous-resend-gate.test.ts": "lib", + "docs-developer-role-policy.test.ts": "ci-workflows" }, "migrated": [ "adapters", diff --git a/structure/overview.md b/structure/overview.md index 13f226e3088..b98c44316d8 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -160,6 +160,13 @@ still cover the rule, which is a judgement only review makes. widens a send budget: an authorised replacement still has to fit the allowance the leg already had. Enforced by `tests/lib/ambiguous-resend-gate.test.ts`. +- **INV-CHAT-01** — One developer-role policy governs the translated Chat wire and every document + that describes it. `foldDeveloperRoleToSystem` unset and `true` send `system`, `false` sends + `developer`, and the message never leaves the slot it arrived in. The documented sentence is + built from the role the adapter serializes rather than written out again, and the translated + pages are compared against their English source, so a changed default fails a check instead of + leaving two documents to disagree; see [`chat-compat.md`](providers/chat-compat.md). + Enforced by `tests/ci-workflows/docs-developer-role-policy.test.ts`. CI enumerates that domain layout through `scripts/ci/run-bun-test-batches.sh`. Its default general scope and 12-file/120-second process shape leave the dedicated Linux storage-policy and api-usage diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index cf601215ed8..41c9f8b1f35 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -24,12 +24,23 @@ stabilization option and does not guarantee upstream cache hits. Regression cove `tests/adapters/openai/openai-chat-system-order.test.ts` and `tests/adapters/openai/openai-chat-developer-position.test.ts`. -The role that slot carries is a separate decision. `developer` is part of the Chat Completions -message role set and is forwarded as itself on every destination. A destination that genuinely -rejects the role sets `foldDeveloperRoleToSystem`, which converts it in place and still never -moves the message. The role was previously decided by testing the base URL host against -`api.openai.com`, so every OpenAI-compatible gateway was assumed not to support a standard role -until proven otherwise, and the instruction silently lost `developer` precedence. +The role that slot carries is a separate decision, and the setting that makes it is tri-state. +`foldDeveloperRoleToSystem` unset sends `system`, `true` sends `system`, and `false` sends +`developer`. Unset means nothing has been recorded about the destination; `true` records an +upstream that rejects the role; `false` records one that accepts it. The message keeps the slot it +arrived in in every case — only the role changes, never the position. The unrecorded state is the +folded one because a destination that rejects the role answers +`400 role 'developer' is not allowed` and the turn never starts, a failure that lands outside this +repository where no test can reach it. The role was previously decided by testing the base URL host +against `api.openai.com`, so every OpenAI-compatible gateway was assumed not to support a standard +role until proven otherwise, and the instruction silently lost `developer` precedence. + +That mapping is not prose to be restated. `tests/ci-workflows/docs-developer-role-policy.test.ts` +builds the sentence above from the role `src/adapters/openai-chat/messages.ts` serializes for each +of the three states, and requires this document and +`docs-site/src/content/docs/reference/configuration/providers.md` to carry it word for word, so a +changed default fails a check rather than only a document review (INV-CHAT-01). The translated +configuration pages and Claude Code guides are held against their English source in the same file. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/tests/ci-workflows/docs-developer-role-policy.test.ts b/tests/ci-workflows/docs-developer-role-policy.test.ts new file mode 100644 index 00000000000..1ac65bce687 --- /dev/null +++ b/tests/ci-workflows/docs-developer-role-policy.test.ts @@ -0,0 +1,152 @@ +/** + * INV-CHAT-01. One developer-role policy for the translated Chat wire and the documents about it. + * + * Three statements were live at once. `structure/providers/chat-compat.md` said `developer` was + * forwarded as itself on every destination, the configuration reference said an unset setting + * sends `system`, and the adapter implemented only one of them. Each reads as plausible on its + * own, so whoever edited this area next could pick any of the three and reintroduce the defect + * the setting exists to prevent — which is how the previous regression arrived. + * + * The sentence under test is built here from the role `createOpenAIChatAdapter` actually + * serializes for each of the three states, so it cannot be held correct by review alone: a + * changed default fails this file rather than leaving one document behind. The translated pages + * are compared against the English source instead of carrying a second copy of the rule, because + * that comparison stays meaningful when the English wording is revised. + * + * Placement — that the message never leaves the slot it arrived in, whichever role it carries — + * is a separate property and stays where it is already tested, in + * `tests/adapters/openai/openai-chat-developer-position.test.ts`. + */ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { repoPath } from "../helpers/repo-root"; + +const INSTRUCTION = "Answer in exactly one sentence."; + +/** The role the Chat wire gives a mid-conversation developer message for one setting state. */ +function wireRole(declared: boolean | undefined): string { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", + ...(declared === undefined ? {} : { foldDeveloperRoleToSystem: declared }), + }; + const parsed = { + modelId: "local-model", + context: { + systemPrompt: ["base instructions"], + messages: [ + { role: "user", content: "First turn.", timestamp: 0 }, + { role: "developer", content: INSTRUCTION, timestamp: 0 }, + { role: "user", content: "Second turn.", timestamp: 0 }, + ], + }, + stream: false, + options: {}, + } as unknown as OcxParsedRequest; + const body = JSON.parse(createOpenAIChatAdapter(provider).buildRequest(parsed).body) as { + messages: Array<{ role: string; content: unknown }>; + }; + const carried = body.messages.find(message => message.content === INSTRUCTION); + expect(carried, "the instruction left the wire; this check has no role to read").toBeDefined(); + return String(carried!.role); +} + +const UNSET = wireRole(undefined); +const DECLARED_REJECTS = wireRole(true); +const DECLARED_ACCEPTS = wireRole(false); + +/** The one sentence both English documents must carry, derived rather than transcribed. */ +const POLICY = + "`foldDeveloperRoleToSystem` unset sends `" + + UNSET + + "`, `true` sends `" + + DECLARED_REJECTS + + "`, and `false` sends `" + + DECLARED_ACCEPTS + + "`."; + +const STRUCTURE_DOC = "structure/providers/chat-compat.md"; +const ENGLISH_REFERENCE = "docs-site/src/content/docs/reference/configuration/providers.md"; +const ENGLISH_GUIDE = "docs-site/src/content/docs/guides/claude-code.md"; +const LOCALES = ["fr", "ja", "ko", "ru", "tr", "zh-cn", "zh-tw"] as const; + +const referencePage = (locale: string): string => + "docs-site/src/content/docs/" + locale + "/reference/configuration/providers.md"; +const guidePage = (locale: string): string => + "docs-site/src/content/docs/" + locale + "/guides/claude-code.md"; + +async function read(path: string): Promise { + return await Bun.file(repoPath(path)).text(); +} + +/** Line wrapping differs between a prose paragraph and a table cell; the sentence does not. */ +const flatten = (text: string): string => text.replace(/\s+/g, " "); + +/** The bare role names a passage carries as code spans, in order. */ +function roleSpans(passage: string): string[] { + return [...passage.matchAll(/`([^`]+)`/g)] + .map(match => match[1]!) + .filter(span => span === "system" || span === "developer"); +} + +async function referenceRow(path: string): Promise { + const rows = (await read(path)) + .split("\n") + .filter(line => line.includes("`foldDeveloperRoleToSystem?`")); + expect(rows.length, path + " has no foldDeveloperRoleToSystem row; re-anchor this check").toBe(1); + return rows[0]!; +} + +async function guideParagraph(path: string): Promise { + const paragraphs = (await read(path)) + .split(/\n\s*\n/) + .filter(block => block.includes("foldDeveloperRoleToSystem")); + expect(paragraphs.length, path + " has no foldDeveloperRoleToSystem paragraph; re-anchor this check").toBe(1); + return paragraphs[0]!; +} + +describe("the documented developer-role policy is derived from the Chat wire", () => { + test("the three states still describe a real choice", () => { + for (const role of [UNSET, DECLARED_REJECTS, DECLARED_ACCEPTS]) { + expect(["system", "developer"]).toContain(role); + } + // All three collapsing to one role would make every assertion below vacuous while the + // documents kept explaining a setting that no longer decides anything. + expect(new Set([UNSET, DECLARED_REJECTS, DECLARED_ACCEPTS]).size).toBe(2); + }); + + test("the structure contract states the mapping the adapter implements", async () => { + expect(flatten(await read(STRUCTURE_DOC))).toContain(POLICY); + }); + + test("the configuration reference states the same mapping", async () => { + expect(flatten(await referenceRow(ENGLISH_REFERENCE))).toContain(POLICY); + }); + + test("the Claude Code guide names the same default and the same accepted role", async () => { + const paragraph = flatten(await guideParagraph(ENGLISH_GUIDE)); + expect(paragraph).toContain("sent as `" + UNSET + "` unless"); + expect(paragraph).toContain("accepts the `" + DECLARED_ACCEPTS + "` role"); + }); +}); + +describe("translated pages do not contradict the English source", () => { + test("the English pages carry roles worth comparing", async () => { + expect(roleSpans(await referenceRow(ENGLISH_REFERENCE)).length).toBeGreaterThan(0); + expect(roleSpans(await guideParagraph(ENGLISH_GUIDE)).length).toBeGreaterThan(0); + }); + + for (const locale of LOCALES) { + test(locale + " states the roles in the order the English reference does", async () => { + const english = roleSpans(await referenceRow(ENGLISH_REFERENCE)); + expect(roleSpans(await referenceRow(referencePage(locale)))).toEqual(english); + }); + + test(locale + " Claude Code guide states the roles the English guide does", async () => { + const english = roleSpans(await guideParagraph(ENGLISH_GUIDE)); + expect(roleSpans(await guideParagraph(guidePage(locale)))).toEqual(english); + }); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9299cbb92a5..3d3074665c8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1456,5 +1456,6 @@ "claude-intercept-integration.test.ts": "server", "management-provider-reset-replay.test.ts": "server", "responses-reset-replay.test.ts": "responses", - "ambiguous-resend-gate.test.ts": "lib" + "ambiguous-resend-gate.test.ts": "lib", + "docs-developer-role-policy.test.ts": "ci-workflows" } From a6e8df4ec1e5fdbdaa92fa7c6f44be22d3d726ef Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 07:52:19 +0900 Subject: [PATCH 52/87] test(codex): judge the paginated-history transition by conversation destination (#5375) The provider-table transition on a home Codex has already migrated to paginated history now completes, but completing it is not the property that matters: a transition that succeeds while moving the conversations a home already had onto Codex's own OpenAI endpoint is worse than the refusal it replaced. This adds a sibling acceptance file that reads the state after the write and resolves, for every conversation the home already had and for the one it will create next, which destination Codex would use. The resolution mirrors Codex's own rule: an openai-tagged row reads the built-in entry and therefore the root openai_base_url, any other tag reads its own provider table. A config that names a provider it never defines fails there instead of passing on key presence. Four cases. Enabling on a paginated openai home keeps both an openai-tagged and an opencodex-tagged conversation on the proxy while equipping the next one with the injected provider and the catalog path. A root override the operator owns is neither claimed by the transition nor deleted by the restore that follows. An admission-token home is refused with nothing written rather than reported as supported. A missing state database and an unreadable backup manifest stay distinct refusals that stop the injector rather than folding into the supported path. The file goes beside the existing cases rather than into them because the file-size ratchet only moves downward. It is registered in both layout maps. The proxy base URL and both stand-down reasons are imported from the source that defines them. --- scripts/test-layout/layout.json | 1 + ...-paginated-transition-destinations.test.ts | 332 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 3 files changed, 334 insertions(+) create mode 100644 tests/codex-integration/history-paginated-transition-destinations.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 53e13ca65a3..a3a3339d92e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -830,6 +830,7 @@ "history-migration-guardian.test.ts": "codex-integration", "history-ocx-compaction-recovery.test.ts": "codex-integration", "history-paginated-openai-compat.test.ts": "codex-integration", + "history-paginated-transition-destinations.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", "hyperbolic-provider.test.ts": "providers", diff --git a/tests/codex-integration/history-paginated-transition-destinations.test.ts b/tests/codex-integration/history-paginated-transition-destinations.test.ts new file mode 100644 index 00000000000..6ff4f56db66 --- /dev/null +++ b/tests/codex-integration/history-paginated-transition-destinations.test.ts @@ -0,0 +1,332 @@ +/** + * What the paginated-history transition leaves behind, judged by destination. + * + * #5321 made the provider-table transition complete on a home Codex has already migrated to + * paginated history. Completing it is not the property that matters to the operator: a + * transition that succeeds while quietly moving the conversations a home already had onto + * Codex's own OpenAI endpoint is worse than the refusal it replaced. Every case below reads + * the state AFTER the write and resolves, for each conversation the home already had and for + * the one it will create next, which destination Codex would actually use. + * + * Nothing here starts the proxy or any service. The injector runs in a child process against a + * temporary CODEX_HOME so its module-level path constants bind to the fixture, which is the + * isolation the neighbouring integration file already uses. + */ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Database } from "bun:sqlite"; + +import { + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, + HISTORY_RELABEL_STANDS_DOWN, +} from "../../src/codex/history-provider"; +import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { validHistoryBackupFixture } from "../helpers/codex-history-manifest-fixtures"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +const repoRoot = resolveRepoRoot(); +setDefaultTimeout(SPAWN_BUDGET_MS); + +const PORT = 10100; + +/** Derived from the injector's own target builder, so the URL under test is never a literal. */ +const PROXY_BASE_URL = standaloneCodexRoutingTarget(PORT, { codexDesktopAuthless: true }).baseUrl; + +/** + * Where a conversation ends up when nothing this project wrote applies to it: Codex's built-in + * `openai` entry with no root override, or a `model_provider` id whose table is absent. Both are + * "not this proxy", which is the outcome these cases exist to rule out. + */ +const CODEX_OWN_ENDPOINT = "codex-built-in-openai-endpoint"; + +/** + * The resolution Codex performs, applied to the config that was actually written. + * + * An `openai`-tagged conversation reads the built-in entry, whose base URL is the root + * `openai_base_url` when one is present. Any other tag reads its own provider table. Asserting + * on key presence would have passed for a config that names a provider it never defines. + */ +function destinationOf(config: Record, modelProvider: string): string { + if (modelProvider === "openai") { + const override = config.openai_base_url; + return typeof override === "string" ? override : CODEX_OWN_ENDPOINT; + } + const tables = config.model_providers; + const table = tables && typeof tables === "object" + ? (tables as Record)[modelProvider] + : undefined; + const baseUrl = table && typeof table === "object" + ? (table as Record).base_url + : undefined; + return typeof baseUrl === "string" ? baseUrl : CODEX_OWN_ENDPOINT; +} + +interface ThreadRow { + id: string; + model_provider: string; + history_mode: string; +} + +function paginatedRollout(id: string, modelProvider: string): string { + return JSON.stringify({ + ordinal: 0, + type: "session_meta", + payload: { id, history_mode: "paginated", model_provider: modelProvider }, + }) + "\n"; +} + +/** Seed one home: the config bytes, plus a paginated row and rollout per conversation. */ +function seedHome( + codexHome: string, + configText: string, + conversations: readonly { id: string; provider: string }[], +): Map { + writeFileSync(join(codexHome, "config.toml"), configText); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.run("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); + const rollouts = new Map(); + for (const conversation of conversations) { + const path = join(codexHome, conversation.id + ".jsonl"); + const bytes = paginatedRollout(conversation.id, conversation.provider); + writeFileSync(path, bytes); + rollouts.set(path, bytes); + db.run( + "INSERT INTO threads VALUES (?, ?, ?, 'paginated')", + conversation.id, + path, + conversation.provider, + ); + } + db.close(); + return rollouts; +} + +function readThreads(codexHome: string): ThreadRow[] { + const db = new Database(join(codexHome, "state_5.sqlite"), { readonly: true }); + const rows = db + .query("SELECT id, model_provider, history_mode FROM threads ORDER BY id") + .all(); + db.close(); + return rows; +} + +function readConfig(codexHome: string): Record { + return Bun.TOML.parse(readFileSync(join(codexHome, "config.toml"), "utf8")) as Record; +} + +function runChild( + codexHome: string, + ocxHome: string, + script: string, + extraEnv: Record = {}, +): { stdout: string; stderr: string; status: number } { + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, ...extraEnv, CODEX_HOME: codexHome, CODEX_SQLITE_HOME: "", OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + return { + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() ?? "", + status: result.status ?? 1, + }; +} + +const INJECT_SCRIPT = [ + 'const { injectCodexConfig } = require("./src/codex/inject");', + 'const result = await injectCodexConfig(' + PORT + ', JSON.parse(process.env.TEST_OCX_CONFIG));', + "console.log(JSON.stringify(result));", +].join("\n"); + +const RESTORE_SCRIPT = [ + 'const { restoreNativeCodex } = require("./src/codex/inject");', + "console.log(JSON.stringify(restoreNativeCodex()));", +].join("\n"); + +/** Run a child and parse its single JSON line, reporting the child's stderr when it failed. */ +function runJson( + codexHome: string, + ocxHome: string, + script: string, + extraEnv: Record = {}, +): Record { + const child = runChild(codexHome, ocxHome, script, extraEnv); + expect(child.status, child.stderr).toBe(0); + return JSON.parse(child.stdout) as Record; +} + +describe("paginated transition destinations (#5321)", () => { + let codexHome: string; + let ocxHome: string; + + beforeEach(() => { + codexHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-l4-codex-"))); + ocxHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-l4-home-"))); + }); + + afterEach(() => { + removeTreeWithRetry(codexHome); + removeTreeWithRetry(ocxHome); + }); + + test("enabling on a paginated openai home keeps every conversation reaching this proxy", () => { + const catalogPath = join(codexHome, "opencodex-catalog.json"); + writeFileSync(catalogPath, JSON.stringify({ models: [{ slug: "vendor/routed-model" }] })); + const rollouts = seedHome(codexHome, 'model = "gpt-5.5"\n', [ + { id: "already-openai", provider: "openai" }, + { id: "already-routed", provider: "opencodex" }, + ]); + const before = readThreads(codexHome); + + const applied = runJson(codexHome, ocxHome, INJECT_SCRIPT, { + TEST_OCX_CONFIG: JSON.stringify({ codexDesktopAuthless: true }), + }); + expect(applied).toMatchObject({ + success: true, + historyPreflightFailureReason: HISTORY_RELABEL_STANDS_DOWN, + }); + + const config = readConfig(codexHome); + // Each conversation the home already had, resolved through whichever entry its own tag + // names. The openai-tagged one is the case #5321 was about: it is never relabeled, so the + // retained root override is the only thing keeping it off Codex's own endpoint. + for (const row of readThreads(codexHome)) { + expect(destinationOf(config, row.model_provider)).toBe(PROXY_BASE_URL); + } + // The conversation created next reads the root model_provider, so the id it names has to + // exist as a table and that table has to point here. + expect(config.model_provider).toBe("opencodex"); + expect(destinationOf(config, config.model_provider as string)).toBe(PROXY_BASE_URL); + expect(config.model_catalog_json).toBe(catalogPath); + expect(existsSync(config.model_catalog_json as string)).toBe(true); + // Preserving those destinations may not cost a history byte or a row: the ordinals in a + // paginated rollout belong to Codex's own writer. + for (const [path, bytes] of rollouts) expect(readFileSync(path, "utf8")).toBe(bytes); + expect(readThreads(codexHome)).toEqual(before); + }); + + test("a root override the operator owns is neither taken over nor removed by the restore", () => { + const operatorUrl = "https://gateway.operator.example/v1"; + const rollouts = seedHome(codexHome, [ + 'openai_base_url = "' + operatorUrl + '"', + 'user_owned = "keep-me"', + 'model = "gpt-5.5"', + "", + ].join("\n"), [{ id: "already-openai", provider: "openai" }]); + + const applied = runJson(codexHome, ocxHome, INJECT_SCRIPT, { + TEST_OCX_CONFIG: JSON.stringify({ codexDesktopAuthless: true }), + }); + expect(applied.success).toBe(true); + + const written = readFileSync(join(codexHome, "config.toml"), "utf8"); + const config = Bun.TOML.parse(written) as Record; + // Their line, their destination. The marker that would claim the line as ours is never + // written above it, which is what stops a later restore from deleting their setting. + expect(config.openai_base_url).toBe(operatorUrl); + expect(written).not.toContain(OCX_ROUTING_MARKER_LINE + "\nopenai_base_url"); + expect(destinationOf(config, "openai")).toBe(operatorUrl); + // The transition is still a transition: new threads reach this proxy through the table. + expect(destinationOf(config, config.model_provider as string)).toBe(PROXY_BASE_URL); + + expect(runJson(codexHome, ocxHome, RESTORE_SCRIPT).success).toBe(true); + const afterRestore = readConfig(codexHome); + expect(afterRestore.openai_base_url).toBe(operatorUrl); + expect(afterRestore.user_owned).toBe("keep-me"); + expect(afterRestore.model_provider).toBeUndefined(); + for (const [path, bytes] of rollouts) expect(readFileSync(path, "utf8")).toBe(bytes); + }); + + test("an admission-token home is refused rather than reported as supported", () => { + const original = 'model = "gpt-5.5"\n'; + const rollouts = seedHome(codexHome, original, [{ id: "already-openai", provider: "openai" }]); + const before = readThreads(codexHome); + + const script = [ + 'const { injectCodexConfig } = require("./src/codex/inject");', + 'const result = await injectCodexConfig(' + PORT + ', {}, { routingTarget: {', + ' baseUrl: "https://hub.example.test/v1",', + " requiresAdmissionToken: true,", + ' tokenEnv: "OPENCODEX_API_AUTH_TOKEN",', + "} });", + "console.log(JSON.stringify(result));", + ].join("\n"); + expect(runJson(codexHome, ocxHome, script)).toMatchObject({ + success: false, + historyPreflightFailureReason: HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, + }); + // Codex's built-in openai entry carries no admission header, so there is no root override + // this form can own. Refusing has to mean nothing was written: a refusal reported beside a + // half-applied config is the state an operator cannot diagnose without reading the file. + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); + expect(readThreads(codexHome)).toEqual(before); + for (const [path, bytes] of rollouts) expect(readFileSync(path, "utf8")).toBe(bytes); + }); + + test("a missing database and an unreadable manifest stay their own failure class", () => { + const original = 'model = "gpt-5.5"\n'; + writeFileSync(join(codexHome, "config.toml"), original); + const rolloutPath = join(codexHome, "manifest-owned.jsonl"); + writeFileSync(rolloutPath, JSON.stringify({ + type: "session_meta", + payload: { id: "manifest-owned", model_provider: "openai", source: "cli" }, + }) + "\n"); + + // No state database at all, and a manifest that still owns restore work. The manifest name + // is derived inside the child, because it binds to the path spelling the runtime resolves. + const script = [ + 'const fs = require("node:fs");', + 'const { dirname, join } = require("node:path");', + 'const { historyBackupPathFor, preflightCodexHistoryInjection } = require("./src/codex/history-provider");', + 'const { resolveCodexStateDbPath } = require("./src/codex/paths");', + 'const { injectCodexConfig } = require("./src/codex/inject");', + "const dbPath = resolveCodexStateDbPath();", + "const manifestPath = historyBackupPathFor(dbPath);", + "const manifest = JSON.parse(process.env.TEST_OCX_MANIFEST);", + "manifest.stateDbPath = dbPath;", + "fs.mkdirSync(dirname(manifestPath), { recursive: true });", + "fs.writeFileSync(manifestPath, JSON.stringify(manifest));", + "const missing = {", + " preflight: preflightCodexHistoryInjection(false, true),", + ' inject: await injectCodexConfig(' + PORT + ', {}),', + "};", + 'fs.writeFileSync(manifestPath, "{ not json");', + "const unreadable = {", + " preflight: preflightCodexHistoryInjection(false, true),", + ' inject: await injectCodexConfig(' + PORT + ', {}),', + "};", + 'const config = fs.readFileSync(join(process.env.CODEX_HOME, "config.toml"), "utf8");', + "console.log(JSON.stringify({ missing, unreadable, config, dbAbsent: !fs.existsSync(dbPath) }));", + ].join("\n"); + const observed = runJson(codexHome, ocxHome, script, { + TEST_OCX_MANIFEST: JSON.stringify(validHistoryBackupFixture("", rolloutPath)), + }) as { + dbAbsent: boolean; + config: string; + missing: { preflight: string; inject: { success: boolean; historyPreflightFailureReason?: string } }; + unreadable: { preflight: string; inject: { success: boolean; historyPreflightFailureReason?: string } }; + }; + + expect(observed.dbAbsent).toBe(true); + // Neither condition may be folded into the path that completes a transition. The two + // stand-down reasons are the supported path; these are not, they are distinct from each + // other, and each one stops the injector before it writes. + const standDown = [HISTORY_RELABEL_STANDS_DOWN, HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE]; + for (const observation of [observed.missing, observed.unreadable]) { + expect(typeof observation.preflight).toBe("string"); + expect(standDown).not.toContain(observation.preflight); + expect(observation.inject.success).toBe(false); + expect(observation.inject.historyPreflightFailureReason).toBe(observation.preflight); + } + expect(observed.unreadable.preflight).not.toBe(observed.missing.preflight); + expect(observed.config).toBe(original); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3d3074665c8..c582661f32d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -657,6 +657,7 @@ "history-migration-guardian.test.ts": "codex-integration", "history-ocx-compaction-recovery.test.ts": "codex-integration", "history-paginated-openai-compat.test.ts": "codex-integration", + "history-paginated-transition-destinations.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", "hyperbolic-provider.test.ts": "providers", From b20acc79d2e95d703fe39fc6985ce83368208cc3 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 08:13:54 +0900 Subject: [PATCH 53/87] fix(server,xai): enforce the request's tool selection after a response repair (#5376) * fix(server): keep a reconstructed Grok terminal inside the request's tool selection The sparse-terminal repair rebuilds a terminal output from the output_item.done events it collected, and it received a budget but nothing about what the request had actually selected. The undeclared-tool guard answers a different question -- whether a name was declared -- so a request sent with tool_choice: "none", with a forced selector naming another tool, or with an allowed_tools list that excludes the call still received that call back through the repair. Read the boundary from the final outbound body, after every removal, rename and translation, and apply it to what the repair publishes. A catalog that ends up empty there authorizes no client call whatever the selector still says; an absent catalog states no boundary, exactly as it states none for the declaration guard. Keep the failure narrow and visible. Only the offending item is withheld, so the assistant text that arrived in the same turn still reaches the client instead of being discarded with it, and the withheld position is kept so the contiguity proof still covers the whole output. Because the turn no longer ended the way the upstream said it did, the reconstructed terminal is published as response.incomplete with incomplete_details.reason forbidden_tool_call rather than as a clean response.completed with a quietly shorter output. The raw stream is still forwarded untouched; policing it remains the declaration guard's job. Co-authored-by: luvs01 * fix(xai): drop a selector that normalization left with nothing to select xAI rejects a Responses request whose tool_choice survives a catalog the adapter had to empty, which is what happens to a cached-only web-search declaration: it is omitted rather than widened to live search, and the request then selects from a catalog it no longer has. Omit an auto or none selector once no tool remains in either the top-level catalog or additional_tools. A forced function selector is preserved: a selector this proxy cannot honor is a client input error, and the request-build path already answers it with a 400 rather than silently turning "call this tool" into "answer however you like". This is the outbound half of the same rule the response-side repair applies -- compatibility is judged on the final request and the final response, after every removal, rename and translation. The change is carried unmodified from #5350. Co-authored-by: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> * docs(structure): record how a tool selection survives a response repair The Responses transport document described declaration enforcement and said nothing about selection, so the next person to touch this area would have read the undeclared-tool guard as the whole contract. Write down the boundary between the two questions, where the scope is read from, and why the refusal keeps the text and marks the terminal instead of finishing quietly. * fix(xai): restate the prohibition the empty-catalog selector omission removes Omitting an auto/none selector that normalization left with nothing to select keeps xAI from answering 400, but the two words are not interchangeable. auto selects from the catalog, so removing it from a request with an empty one states nothing new. none is a prohibition, and on a request whose catalog this normalizer just emptied it is the only place the turn's client-call boundary is written down. The sparse-terminal repair reads that boundary from the final outbound body, so dropping the word alone handed the reconstruction a request that authorized more than the caller did -- and nothing behind it catches that: the repair runs on the grok client surface while the declaration guard stands down whenever the provider's authMode is forward, which is what the xAI OAuth lane is. A caller who forbade every client tool could get one back inside a terminal the upstream never sent. Restate the prohibition as the explicit empty catalog. It carries the same deny-all, the request scope and the declaration guard both already read it that way, and this destination receives it unchanged whenever a caller sends one itself. auto is still dropped without inventing a catalog, because an absent catalog states no boundary. Co-authored-by: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> --------- Co-authored-by: luvs01 Co-authored-by: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> --- scripts/test-layout/layout.json | 2 + src/adapters/xai-web-search.ts | 17 +- src/server/grok-responses-snapshot-repair.ts | 115 ++++++++- src/server/responses-request-tool-scope.ts | 121 +++++++++ src/server/responses-undeclared-tool-guard.ts | 5 +- src/server/responses/passthrough-delivery.ts | 2 +- structure/transports/responses.md | 41 +++ tests/fixtures/test-layout-expected.json | 2 + .../xai/xai-empty-catalog-tool-choice.test.ts | 155 ++++++++++++ .../xai/xai-web-search-compat.test.ts | 8 +- ...sponses-sparse-terminal-tool-scope.test.ts | 235 ++++++++++++++++++ 11 files changed, 688 insertions(+), 15 deletions(-) create mode 100644 src/server/responses-request-tool-scope.ts create mode 100644 tests/providers/xai/xai-empty-catalog-tool-choice.test.ts create mode 100644 tests/responses/responses-sparse-terminal-tool-scope.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a3a3339d92e..81035d9b1e7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1297,6 +1297,7 @@ "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", + "responses-sparse-terminal-tool-scope.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", @@ -1557,6 +1558,7 @@ "ws-upstream.test.ts": "responses", "ws-upstream-socks5.test.ts": "responses", "xai-client.test.ts": "images", + "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", "xai-responses-adjacency.test.ts": "providers/xai", diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts index e558e39f2ed..4dbb77d7364 100644 --- a/src/adapters/xai-web-search.ts +++ b/src/adapters/xai-web-search.ts @@ -1,4 +1,5 @@ import type { OcxProviderConfig } from "../types"; +import { debugProviderDiagnostic } from "../lib/debug"; import { isXaiResponsesDestination } from "../providers/xai-transport"; const CODEX_WEB_SEARCH_TOOL = "web_search"; @@ -192,7 +193,21 @@ export function normalizeXaiResponsesWebSearch( if (inputChanged) next = { ...next, input }; } - return normalizeToolChoice(next); + const normalized = normalizeToolChoice(next); + const choice = normalized.tool_choice; + if ((choice === "auto" || choice === "none") && !hasAnyDeclaredTool(normalized)) { + debugProviderDiagnostic("xai", "tool-choice-omitted", { choice }); + const { tool_choice: _toolChoice, ...rest } = normalized; + // `auto` selects from the catalog, so a catalog with nothing in it makes it meaningless and + // the omission says nothing the request did not already say. `none` is the opposite: it is a + // prohibition, and on a request whose catalog this normalizer just emptied it is the only + // place the turn's client-call boundary is written down. Downstream repair reads that + // boundary off the final outbound body, so omitting the word alone would hand back a call the + // caller ruled out. Restate it as the explicit empty catalog, which carries the same deny-all + // and which this destination already receives whenever a caller sends one itself. + return choice === "none" && !Array.isArray(rest.tools) ? { ...rest, tools: [] } : rest; + } + return normalized; } function isLiveWebSearchTool(tool: unknown): boolean { diff --git a/src/server/grok-responses-snapshot-repair.ts b/src/server/grok-responses-snapshot-repair.ts index a667f44e551..5cbb32fd9c8 100644 --- a/src/server/grok-responses-snapshot-repair.ts +++ b/src/server/grok-responses-snapshot-repair.ts @@ -1,8 +1,9 @@ /** Strict terminal reconstruction selected by the Grok compatibility marker. */ import type { TranslatorBudget } from "../lib/translator-budget"; import { MAX_COMPLETED_OUTPUT_ITEMS, MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES } from "./relay"; -import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec"; +import { requestToolScope, type RequestToolScope } from "./responses-request-tool-scope"; type SparseTerminalOpenItem = { type: string; @@ -16,6 +17,20 @@ type SparseTerminalCompletedItem = RetainedOutputItem & { const MAX_GROK_OPEN_ITEM_IDENTITY_BYTES = MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES; +/** Terminal event this repair publishes when it refused to reconstruct a call faithfully. */ +export const GROK_REFUSED_TERMINAL_EVENT_TYPE = "response.incomplete"; + +/** `incomplete_details.reason` carried by that terminal. */ +export const GROK_FORBIDDEN_TOOL_CALL_REASON = "forbidden_tool_call"; + +/** An upstream-supplied name reaches the terminal message; keep it bounded. */ +const MAX_REPORTED_TOOL_NAME_CHARS = 100; + +export function forbiddenToolCallMessage(name: string): string { + return `routed provider called "${name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS)}", ` + + "which this request's tool selection excludes; the reconstructed output omits that call"; +} + const GROK_TERMINAL_OUTPUT_ITEM_TYPES = new Set([ "message", "reasoning", @@ -167,6 +182,45 @@ function plausibleGrokOpenItem( }; } +/** + * Publish the refusal on the terminal itself rather than as a silent omission. + * + * The ordinary reconstruction replaces only the data payload's `output`, so its event name still + * describes the payload. A refusal does not: the turn no longer completed the way the upstream + * said it did, so the event line moves with the status instead of leaving a client to read a + * clean finish off an unchanged `event: response.completed`. + */ +function refusedTerminalBlock( + block: string, + parsed: Record, + response: Record, + output: readonly Record[], + refusedName: string | undefined, +): string { + const payload = JSON.stringify({ + ...parsed, + type: GROK_REFUSED_TERMINAL_EVENT_TYPE, + response: { + ...response, + status: "incomplete", + output, + incomplete_details: { + reason: GROK_FORBIDDEN_TOOL_CALL_REASON, + ...(refusedName === undefined ? {} : { message: forbiddenToolCallMessage(refusedName) }), + }, + }, + }); + const rewritten = replaceSseDataPayload(block, payload); + const newline = block.includes("\r\n") ? "\r\n" : "\n"; + let eventRewritten = false; + const lines = rewritten.split(/\r?\n/).map(line => { + if (eventRewritten || !line.startsWith("event:")) return line; + eventRewritten = true; + return `event: ${GROK_REFUSED_TERMINAL_EVENT_TYPE}`; + }); + return lines.join(newline); +} + /** * Narrow client repair for grok-build's Responses consumer. * @@ -176,12 +230,22 @@ function plausibleGrokOpenItem( * empty output. Reconstruct only from real, unique, contiguous, bounded done * events whose raw semantics are already valid. Any ambiguity stays byte-level * fail-closed; the provider-opt-in snapshot repair above is unchanged. + * + * The terminal this publishes is one the upstream never sent, so it carries only what the final + * outbound request still authorized. A client call outside that request's tool selection is left + * out and the terminal says so explicitly. The declaration guard downstream answers the other + * half of the question — whether a name was declared at all — and keeps policing the raw stream, + * which this rewrite never edits. */ export function createGrokResponsesSparseTerminalBlockRewrite( budget?: TranslatorBudget, + outboundRequestBody?: unknown, ): SseBlockRewrite { + const toolScope: RequestToolScope | undefined = requestToolScope(outboundRequestBody); const openItems = new Map(); const completedItems = new Map(); + const withheldIndices = new Set(); + let withheldToolName: string | undefined; let aggregateItemBytes = 0; let aggregateOpenItemBytes = 0; let tainted = false; @@ -194,6 +258,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite( } openItems.clear(); completedItems.clear(); + withheldIndices.clear(); + withheldToolName = undefined; aggregateItemBytes = 0; aggregateOpenItemBytes = 0; hasVisibleOutput = false; @@ -217,7 +283,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite( if (tainted) return; const sourceBytes = Buffer.byteLength(JSON.stringify(item), "utf8"); if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES - || completedItems.size >= MAX_COMPLETED_OUTPUT_ITEMS + || completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS || aggregateItemBytes + sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) { taintAndRelease(); return; @@ -228,6 +294,24 @@ export function createGrokResponsesSparseTerminalBlockRewrite( hasVisibleOutput = hasVisibleOutput || visibleToGrok; }; + /** + * Record the position of a call this request forbade without retaining the item. + * + * Only the offending item is dropped. Tainting here instead would discard the assistant text + * that arrived in the same turn and leave the client the empty terminal this repair exists to + * fix, which punishes the caller for the provider's overreach. The index is kept so the + * contiguity proof below still covers the whole output. + */ + const withholdForbiddenCall = (index: number, name: string): void => { + if (tainted) return; + if (completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS) { + taintAndRelease(); + return; + } + withheldIndices.add(index); + withheldToolName ??= name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS); + }; + const closeOpenItem = (index: number): void => { const open = openItems.get(index); if (!open) return; @@ -265,6 +349,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite( const open = isPlainObject(parsed.item) ? plausibleGrokOpenItem(parsed.item) : null; if (outputIndex === undefined || !open || openItems.has(outputIndex) || completedItems.has(outputIndex) + || withheldIndices.has(outputIndex) || openItems.size >= MAX_COMPLETED_OUTPUT_ITEMS) { taintAndRelease(); } else if (!tainted) { @@ -284,7 +369,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite( if (type === "response.output_item.done") { const item = isPlainObject(parsed.item) ? parsed.item : null; const proof = item ? trustedGrokCompletedItem(item) : null; - if (outputIndex === undefined || !proof || completedItems.has(outputIndex)) { + if (outputIndex === undefined || !proof + || completedItems.has(outputIndex) || withheldIndices.has(outputIndex)) { taintAndRelease(); return [block]; } @@ -295,7 +381,12 @@ export function createGrokResponsesSparseTerminalBlockRewrite( return [block]; } closeOpenItem(outputIndex); - retainCompletedItem(outputIndex, item!, proof.visibleToGrok); + const forbidden = toolScope?.forbiddenClientToolCallName(item!); + if (forbidden === undefined) { + retainCompletedItem(outputIndex, item!, proof.visibleToGrok); + } else { + withholdForbiddenCall(outputIndex, forbidden); + } return [block]; } @@ -312,14 +403,18 @@ export function createGrokResponsesSparseTerminalBlockRewrite( const outputIsAuthoritative = Array.isArray(output) && output.length > 0; const outputIsSparse = !("output" in response) || (Array.isArray(output) && output.length === 0); + // A withheld call is a reason to publish on its own: the refusal has to reach the client + // even when nothing visible survived it, or the turn ends as an ordinary empty finish. + const refused = withheldIndices.size > 0; if (!outputIsAuthoritative && outputIsSparse && terminalStatusConsistent - && completedItems.size > 0 && openItems.size === 0 && hasVisibleOutput) { + && openItems.size === 0 && (hasVisibleOutput || refused)) { const ordered = [...completedItems.entries()].sort(([left], [right]) => left - right); - if (ordered.every(([index], position) => index === position)) { - out = jsonBlock({ - ...parsed, - response: { ...response, output: ordered.map(([, retained]) => retained.item) }, - }); + const positions = [...completedItems.keys(), ...withheldIndices].sort((left, right) => left - right); + if (positions.length > 0 && positions.every((index, position) => index === position)) { + const rebuilt = ordered.map(([, retained]) => retained.item); + out = refused + ? refusedTerminalBlock(block, parsed, response, rebuilt, withheldToolName) + : jsonBlock({ ...parsed, response: { ...response, output: rebuilt } }); } } } diff --git a/src/server/responses-request-tool-scope.ts b/src/server/responses-request-tool-scope.ts new file mode 100644 index 00000000000..8531ff9b2e7 --- /dev/null +++ b/src/server/responses-request-tool-scope.ts @@ -0,0 +1,121 @@ +/** + * The tool selection a Responses request actually authorized, read from the final outbound body. + * + * The undeclared-tool guard answers whether a NAME was declared. This answers a different + * question: whether this request still permits a client tool call at all, and which names it + * permits. `tool_choice: "none"`, a forced selector and an `allowed_tools` allow-list each narrow + * the catalog without removing a declaration, so a name can be declared and forbidden at the same + * time — and a repair that rebuilds a terminal from collected items would otherwise hand the + * client a call the caller ruled out. + * + * The scope is read from the OUTBOUND body, after every removal, rename and translation, because + * that is the request the destination answered. A catalog that ends up empty there authorizes no + * client call whatever the selector still says. + */ +import { dottedToolName, namespacedToolName } from "../types"; +import { + CLIENT_EXECUTED_CALL_TYPES, + collectDeclaredWireToolNames, + hasExplicitWireToolCatalog, +} from "./responses-undeclared-tool-guard"; +import { isPlainObject } from "./responses-snapshot-codec"; + +/** Every spelling one call item can be named by, so a selector match is not defeated by flattening. */ +function callNameSpellings(item: Record): readonly string[] { + const name = typeof item.name === "string" ? item.name : ""; + if (name.length === 0) return []; + const namespace = typeof item.namespace === "string" && item.namespace.length > 0 + ? item.namespace + : undefined; + if (!namespace) return [name]; + return [name, namespacedToolName(namespace, name), dottedToolName(namespace, name)]; +} + +/** The names one `tool_choice` entry selects; empty when the entry names no client tool. */ +function selectorNameSpellings(selector: unknown): readonly string[] { + if (!isPlainObject(selector)) return []; + const name = typeof selector.name === "string" ? selector.name : ""; + if (name.length === 0) return []; + const namespace = typeof selector.namespace === "string" && selector.namespace.length > 0 + ? selector.namespace + : undefined; + if (!namespace) return [name]; + return [name, namespacedToolName(namespace, name), dottedToolName(namespace, name)]; +} + +type ToolSelection = + | { readonly kind: "unrestricted" } + | { readonly kind: "deny_all" } + | { readonly kind: "allow"; readonly names: ReadonlySet }; + +const UNRESTRICTED: ToolSelection = { kind: "unrestricted" }; + +/** + * Read the selector only where it states a client-call boundary. + * + * `auto`, `required` and an absent selector restrict nothing. A hosted selector + * (`{ type: "web_search" }`) forces a tool the PROVIDER runs and does not describe the client + * calls this turn may contain, so it is left alone rather than read as a deny-all: a false + * refusal would drop a call the caller could have executed. + */ +function toolSelection(body: Record): ToolSelection { + const choice = body.tool_choice; + if (choice === "none") return { kind: "deny_all" }; + if (!isPlainObject(choice)) return UNRESTRICTED; + if (choice.type === "allowed_tools") { + if (!Array.isArray(choice.tools)) return UNRESTRICTED; + const names = new Set(); + for (const entry of choice.tools) { + for (const spelling of selectorNameSpellings(entry)) names.add(spelling); + } + // An allow-list carrying no client tool — emptied by normalization, or hosted entries only — + // still bounds this turn: it allows no client call. + return { kind: "allow", names }; + } + if (choice.type === "function" || choice.type === "custom") { + const names = new Set(selectorNameSpellings(choice)); + return names.size > 0 ? { kind: "allow", names } : UNRESTRICTED; + } + return UNRESTRICTED; +} + +export type RequestToolScope = { + /** + * The name a client call is refused under, or undefined when this request permits it. + * A nameless call type is not answered here: only the declaration guard knows those. + */ + forbiddenClientToolCallName(item: Record): string | undefined; +}; + +/** + * The client-call boundary this request states, or undefined when it states none. + * + * Returning undefined for an unrestricted request keeps every ordinary turn on the path it + * already had: a caller that selected nothing gets no new refusal. + */ +export function requestToolScope(body: unknown): RequestToolScope | undefined { + if (!isPlainObject(body)) return undefined; + const selection = toolSelection(body); + // A readable catalog that declares no client-executable name is authoritative, exactly as it is + // for the declaration guard: an explicit empty list denies every client call. An absent catalog + // says nothing — a passthrough request may omit `tools` and still receive a call the client + // understands. + const catalogDeniesClientCalls = hasExplicitWireToolCatalog(body) + && collectDeclaredWireToolNames(body).size === 0; + if (selection.kind === "unrestricted" && !catalogDeniesClientCalls) return undefined; + return { + forbiddenClientToolCallName(item: Record): string | undefined { + if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) { + return undefined; + } + const spellings = callNameSpellings(item); + const reported = spellings[0]; + if (reported === undefined) return undefined; + if (catalogDeniesClientCalls || selection.kind === "deny_all") return reported; + if (selection.kind === "allow") { + return spellings.some(spelling => selection.names.has(spelling)) ? undefined : reported; + } + return undefined; + }, + }; +} diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 7b2a2a22c1c..943f19e7d6d 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -13,7 +13,10 @@ import { import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; /** Item types the client executes through a request-declared wire name. */ -const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); +export const CLIENT_EXECUTED_CALL_TYPES: ReadonlySet = new Set([ + "function_call", + "custom_tool_call", +]); /** Codex groups ordinary top-level tools here; unlike an MCP namespace, it has no wire prefix. */ const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index f6cd0009987..a8ee2f3f4b0 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -515,7 +515,7 @@ export async function deliverPassthroughResponse( ? createGrokResponsesTimestampBlockRewrite() : undefined, grokClientCompatibilityEnabled - ? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget) + ? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget, nativeExchange.outboundRequestBody) : undefined, snapshotRepairEnabled ? createResponsesSnapshotBlockRewrite(nativeExchange.outboundRequestBody, translatorBudget) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 3b40dd43eda..11b9fed453f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -667,6 +667,47 @@ and continuation-state suppression as well as the refusal, and it stands down on `src/server/responses/run-turn-execution.ts` and `src/server/responses/adapter-delivery.ts` set the flag from `inboundWire` on the streaming, buffered, and JSON paths alike, so the three cannot drift. +### Selection outlives the declaration check + +Declaration and selection are different questions, and the guard above answers only the first. +`tool_choice: "none"`, a forced selector and an `allowed_tools` allow-list each narrow a catalog +without removing a declaration, so a name can be declared and forbidden at the same time — and a +guard that compares names against the catalog passes it. + +The gap is reachable because a repair can put such a call back. +`createGrokResponsesSparseTerminalBlockRewrite` in `src/server/grok-responses-snapshot-repair.ts` +rebuilds a terminal `output` the upstream never sent from the items it collected during the turn. +`src/server/responses-request-tool-scope.ts` reads the boundary the request states, and the repair +applies it to what it publishes: a client call outside the selection is left out of the +reconstruction. The scope comes from the final outbound body, after every removal, rename and +translation, so a catalog that ends up empty there authorizes no client call whatever the selector +still says. An absent catalog states no boundary, exactly as it states none for the declaration +guard. + +The refusal is narrow and it is visible. Only the offending item is dropped, so the assistant text +that arrived in the same turn still reaches the client rather than being discarded with it. Because +the turn no longer ended the way the upstream said it did, the reconstructed terminal is published +as `response.incomplete` carrying `incomplete_details.reason: forbidden_tool_call`, not as a clean +`response.completed` with a quietly shorter output. The repair edits nothing but the terminal it +synthesizes; the raw stream remains the declaration guard's to police. + +The selection is kept honest on the way out as well. `src/adapters/xai-web-search.ts` omits an +`auto`/`none` selector once normalization has left nothing for it to select, because xAI answers +that request with a 400. A forced function selector is preserved: a selector this proxy cannot +honor is a client input error, and `src/server/responses/passthrough-dispatch.ts` already answers +it with one. + +Those two omissions are not the same edit, because the scope above is read from the body this +normalization produces. `auto` selects from the catalog, so removing it from a request with an +empty one states nothing new. `none` is a prohibition, and on a request whose catalog this +normalizer emptied it is the only place the turn's client-call boundary is written down. Dropping +the word alone would let the reconstruction hand back a call the caller ruled out, and nothing +behind it would catch that: the repair runs on the grok client surface, while the declaration +guard stands down whenever the provider's `authMode` is `forward` — which is what the xAI OAuth +lane is. So the prohibition is restated as the explicit empty catalog, which carries the same +deny-all, which the scope and the declaration guard both already read that way, and which this +destination receives unchanged whenever a caller sends one itself. + ### Passthrough SSE stream shapes (#314) Native passthrough SSE has TWO shapes, selected per request in diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c582661f32d..01a0b923f0f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1126,6 +1126,7 @@ "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", + "responses-sparse-terminal-tool-scope.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", @@ -1386,6 +1387,7 @@ "ws-upstream.test.ts": "responses", "ws-upstream-socks5.test.ts": "responses", "xai-client.test.ts": "images", + "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", "xai-responses-adjacency.test.ts": "providers/xai", diff --git a/tests/providers/xai/xai-empty-catalog-tool-choice.test.ts b/tests/providers/xai/xai-empty-catalog-tool-choice.test.ts new file mode 100644 index 00000000000..28b2549397c --- /dev/null +++ b/tests/providers/xai/xai-empty-catalog-tool-choice.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeXaiResponsesWebSearch } from "../../../src/adapters/xai-web-search"; +import { + createGrokResponsesSparseTerminalBlockRewrite, + GROK_FORBIDDEN_TOOL_CALL_REASON, + GROK_REFUSED_TERMINAL_EVENT_TYPE, + forbiddenToolCallMessage, +} from "../../../src/server/grok-responses-snapshot-repair"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const XAI_PROVIDER = { baseUrl: "https://api.x.ai/v1" }; + +function normalize(body: Record): Record { + return normalizeXaiResponsesWebSearch(body, XAI_PROVIDER) as Record; +} + +const CALL_ITEM: Record = { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "apply_patch", + arguments: "{}", +}; + +const MESSAGE_ITEM: Record = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "there is nothing to call here", annotations: [] }], +}; + +function dataBlock(payload: unknown): string { + return `data: ${JSON.stringify(payload)}`; +} + +/** Replay a sparse Grok stream against one outbound body and return the terminal it publishes. */ +function reconstructTerminal(outboundBody: unknown): Record { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite( + createTestTranslatorBudget(), + outboundBody, + ); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: CALL_ITEM })); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 1, item: MESSAGE_ITEM })); + const out = rewrite( + `event: response.completed\n${dataBlock({ + type: "response.completed", + response: { id: "resp_1", status: "completed", output: [] }, + })}`, + ); + expect(out).toHaveLength(1); + const data = out[0]!.split(/\r?\n/) + .filter(line => line.startsWith("data: ")) + .map(line => line.slice("data: ".length)) + .join(""); + return JSON.parse(data) as Record; +} + +describe("xAI Responses selectors after tool normalization", () => { + test("a catalog emptied by normalization drops the selector that has nothing left to select", () => { + // The cached-only declaration is omitted above rather than widened to live search, which + // leaves the request selecting from a catalog it no longer has; xAI answers 400. + const body = normalize({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ type: "web_search", external_web_access: false }], + tool_choice: "auto", + }); + + expect(Object.hasOwn(body, "tools")).toBe(false); + expect(Object.hasOwn(body, "tool_choice")).toBe(false); + }); + + test("a prohibition the omission would erase is restated as the explicit empty catalog", () => { + // Normalization turns a selector for the removed search into "none", and the request then + // says nothing else about what this turn may contain. Omitting that word for the wire without + // restating it would leave a body that authorizes more than the caller did. + const body = normalize({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ type: "web_search", external_web_access: false }], + tool_choice: { type: "web_search" }, + }); + + expect(Object.hasOwn(body, "tool_choice")).toBe(false); + expect(body.tools).toEqual([]); + }); + + test("a caller's own prohibition survives an omission it never asked for", () => { + const body = normalize({ model: "grok-4.6", input: "hi", tool_choice: "none" }); + + expect(Object.hasOwn(body, "tool_choice")).toBe(false); + expect(body.tools).toEqual([]); + }); + + test("omitting auto invents no catalog the caller never declared", () => { + // An absent catalog states no boundary, and a passthrough request may legitimately omit + // tools and still receive a call its client understands. + const body = normalize({ model: "grok-4.6", input: "hi", tool_choice: "auto" }); + + expect(Object.hasOwn(body, "tool_choice")).toBe(false); + expect(Object.hasOwn(body, "tools")).toBe(false); + }); + + test("the restated catalog still refuses a forbidden call in a reconstructed terminal", () => { + // The wire-compatibility omission and the reconstruction boundary meet here: the repair reads + // the final outbound body, so a request that forbade every client call must still refuse one + // that arrives in a sparse stream, and must keep the assistant text that arrived beside it. + const terminal = reconstructTerminal(normalize({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ type: "web_search", external_web_access: false }], + tool_choice: { type: "web_search" }, + })); + const response = terminal.response as Record; + + expect(terminal.type).toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + expect(response.output).toEqual([MESSAGE_ITEM]); + expect(response.incomplete_details).toEqual({ + reason: GROK_FORBIDDEN_TOOL_CALL_REASON, + message: forbiddenToolCallMessage(CALL_ITEM.name as string), + }); + }); + + test("an explicitly empty catalog carries no selector either", () => { + for (const choice of ["auto", "none"]) { + const body = normalize({ model: "grok-4.6", input: "hi", tools: [], tool_choice: choice }); + expect(Object.hasOwn(body, "tool_choice")).toBe(false); + } + }); + + test("a selector that still has a tool to select is left alone", () => { + const body = normalize({ + model: "grok-4.6", + input: "hi", + tools: [{ type: "function", name: "read_file" }], + tool_choice: "auto", + }); + + expect(body.tool_choice).toBe("auto"); + }); + + test("a forced function selector survives an empty catalog as a client input error", () => { + // Dropping it would silently turn "call this tool" into "answer however you like"; the + // request-build path answers a selector this proxy cannot honor with a 400 instead. + const body = normalize({ + model: "grok-4.6", + input: "hi", + tools: [], + tool_choice: { type: "function", name: "read_file" }, + }); + + expect(body.tool_choice).toEqual({ type: "function", name: "read_file" }); + }); +}); diff --git a/tests/providers/xai/xai-web-search-compat.test.ts b/tests/providers/xai/xai-web-search-compat.test.ts index 67faa1c3f9e..6dd69540f91 100644 --- a/tests/providers/xai/xai-web-search-compat.test.ts +++ b/tests/providers/xai/xai-web-search-compat.test.ts @@ -75,11 +75,15 @@ describe("xAI Responses web-search compatibility", () => { }, }); - expect(body.tools).toBeUndefined(); expect(body.input).toEqual([ { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, ]); - expect(body.tool_choice).toBe("none"); + // The selector is omitted because xAI rejects one that selects from a catalog this + // normalization emptied, and the deny-all it stated is restated as the explicit empty + // catalog. Both spellings forbid every client call this turn; only the second one survives + // the wire, and the reconstruction path reads the request's boundary from exactly this body. + expect(body).not.toHaveProperty("tool_choice"); + expect(body.tools).toEqual([]); }); test("keeps public xAI search declarations live when the private access flag is absent", () => { diff --git a/tests/responses/responses-sparse-terminal-tool-scope.test.ts b/tests/responses/responses-sparse-terminal-tool-scope.test.ts new file mode 100644 index 00000000000..4b8cd37becd --- /dev/null +++ b/tests/responses/responses-sparse-terminal-tool-scope.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from "bun:test"; +import { + createGrokResponsesSparseTerminalBlockRewrite, + forbiddenToolCallMessage, + GROK_FORBIDDEN_TOOL_CALL_REASON, + GROK_REFUSED_TERMINAL_EVENT_TYPE, +} from "../../src/server/grok-responses-snapshot-repair"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +function dataBlock(payload: unknown): string { + return `data: ${JSON.stringify(payload)}`; +} + +function terminalBlock(payload: unknown): string { + return `event: response.completed\ndata: ${JSON.stringify(payload)}`; +} + +function payloadOf(block: string): Record { + const data = block.split(/\r?\n/) + .filter(line => line.startsWith("data: ")) + .map(line => line.slice("data: ".length)) + .join(""); + return JSON.parse(data) as Record; +} + +function eventNameOf(block: string): string | undefined { + const line = block.split(/\r?\n/).find(candidate => candidate.startsWith("event: ")); + return line === undefined ? undefined : line.slice("event: ".length); +} + +function responseOf(block: string): Record { + return payloadOf(block).response as Record; +} + +const MESSAGE_ITEM: Record = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "here is the answer", annotations: [] }], +}; + +const CALL_ITEM: Record = { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "apply_patch", + arguments: "{}", +}; + +const SPARSE_TERMINAL = { + type: "response.completed", + response: { id: "resp_1", status: "completed", output: [] }, +}; + +function relay( + outboundBody: unknown, + items: readonly { index: number; item: Record }[], +): { terminal: string; forwarded: string[] } { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite( + createTestTranslatorBudget(), + outboundBody, + ); + const forwarded: string[] = []; + for (const { index, item } of items) { + forwarded.push( + ...rewrite(dataBlock({ type: "response.output_item.done", output_index: index, item })), + ); + } + const out = rewrite(terminalBlock(SPARSE_TERMINAL)); + expect(out).toHaveLength(1); + return { terminal: out[0]!, forwarded }; +} + +function ordered(...items: Record[]): { index: number; item: Record }[] { + return items.map((item, index) => ({ index, item })); +} + +describe("Grok sparse terminal reconstruction honours the request's tool selection", () => { + test("a request that selected no tools keeps the text and refuses the call explicitly", () => { + const { terminal } = relay( + { model: "grok-4.6", tools: [{ type: "function", name: "apply_patch" }], tool_choice: "none" }, + ordered(CALL_ITEM, MESSAGE_ITEM), + ); + + expect(payloadOf(terminal).type).toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + expect(eventNameOf(terminal)).toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + const response = responseOf(terminal); + expect(response.status).toBe("incomplete"); + // The forbidden call is the only casualty; the assistant text that arrived with it survives. + expect(response.output).toEqual([MESSAGE_ITEM]); + expect(response.incomplete_details).toEqual({ + reason: GROK_FORBIDDEN_TOOL_CALL_REASON, + message: forbiddenToolCallMessage(CALL_ITEM.name as string), + }); + }); + + test("a refusal with nothing else to publish is still explicit, not an empty clean finish", () => { + const { terminal } = relay( + { model: "grok-4.6", tools: [{ type: "function", name: "apply_patch" }], tool_choice: "none" }, + ordered(CALL_ITEM), + ); + + expect(payloadOf(terminal).type).toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + expect(responseOf(terminal).output).toEqual([]); + }); + + test("the raw stream is forwarded untouched; only the reconstructed terminal is bounded", () => { + const { forwarded } = relay( + { model: "grok-4.6", tools: [{ type: "function", name: "apply_patch" }], tool_choice: "none" }, + ordered(CALL_ITEM), + ); + + expect(forwarded).toEqual([ + dataBlock({ type: "response.output_item.done", output_index: 0, item: CALL_ITEM }), + ]); + }); + + test("a selection that permits the call reconstructs it unchanged", () => { + const { terminal } = relay( + { + model: "grok-4.6", + tools: [{ type: "function", name: "apply_patch" }], + tool_choice: { type: "function", name: "apply_patch" }, + }, + ordered(CALL_ITEM, MESSAGE_ITEM), + ); + + expect(payloadOf(terminal).type).toBe("response.completed"); + expect(responseOf(terminal).output).toEqual([CALL_ITEM, MESSAGE_ITEM]); + }); + + test("a forced selector for a different tool refuses the call it did not select", () => { + const { terminal } = relay( + { + model: "grok-4.6", + tools: [{ type: "function", name: "apply_patch" }, { type: "function", name: "read_file" }], + tool_choice: { type: "function", name: "read_file" }, + }, + ordered(CALL_ITEM, MESSAGE_ITEM), + ); + + expect(payloadOf(terminal).type).toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + expect(responseOf(terminal).output).toEqual([MESSAGE_ITEM]); + }); + + test("an allow-list admits a listed tool and refuses an unlisted one", () => { + const body = { + model: "grok-4.6", + tools: [{ type: "function", name: "apply_patch" }, { type: "function", name: "read_file" }], + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "function", name: "read_file" }], + }, + }; + const listed: Record = { ...CALL_ITEM, name: "read_file" }; + + expect(responseOf(relay(body, ordered(listed)).terminal).output).toEqual([listed]); + expect(payloadOf(relay(body, ordered(CALL_ITEM, MESSAGE_ITEM)).terminal).type) + .toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + }); + + test("a namespaced call matches the selector under either flattened spelling", () => { + const namespaced: Record = { ...CALL_ITEM, name: "search", namespace: "docs" }; + for (const selected of ["docs__search", "docs.search"]) { + const { terminal } = relay( + { + model: "grok-4.6", + tools: [{ type: "namespace", name: "docs", tools: [{ type: "function", name: "search" }] }], + tool_choice: { type: "function", name: selected }, + }, + ordered(namespaced), + ); + expect(responseOf(terminal).output).toEqual([namespaced]); + } + }); + + test("a catalog emptied by normalization admits no client call, with or without a selector", () => { + for (const body of [ + { model: "grok-4.6", tools: [] }, + { model: "grok-4.6", tools: [], tool_choice: "auto" }, + { model: "grok-4.6", tools: [{ type: "web_search" }], tool_choice: "auto" }, + ]) { + const { terminal } = relay(body, ordered(CALL_ITEM, MESSAGE_ITEM)); + expect(payloadOf(terminal).type).toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + expect(responseOf(terminal).output).toEqual([MESSAGE_ITEM]); + } + }); + + test("a request that declares no catalog at all keeps its existing reconstruction", () => { + // A passthrough request may legitimately omit `tools` and still receive a call the client + // understands, so an absent catalog states no boundary for this repair to enforce. + const { terminal } = relay({ model: "grok-4.6", input: [] }, ordered(CALL_ITEM, MESSAGE_ITEM)); + + expect(payloadOf(terminal).type).toBe("response.completed"); + expect(responseOf(terminal).output).toEqual([CALL_ITEM, MESSAGE_ITEM]); + }); + + test("a selection with nothing to refuse reconstructs the ordinary terminal", () => { + const { terminal } = relay( + { model: "grok-4.6", tools: [{ type: "function", name: "apply_patch" }], tool_choice: "none" }, + ordered(MESSAGE_ITEM), + ); + + expect(payloadOf(terminal).type).toBe("response.completed"); + expect(responseOf(terminal).output).toEqual([MESSAGE_ITEM]); + }); + + test("a withheld index never fills a gap the stream actually left", () => { + const { terminal } = relay( + { model: "grok-4.6", tools: [{ type: "function", name: "apply_patch" }], tool_choice: "none" }, + [{ index: 0, item: CALL_ITEM }, { index: 2, item: MESSAGE_ITEM }], + ); + + expect(payloadOf(terminal).type).toBe("response.completed"); + expect(responseOf(terminal).output).toEqual([]); + }); + + test("refusing a call releases every retained byte", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(budget, { + model: "grok-4.6", + tools: [{ type: "function", name: "apply_patch" }], + tool_choice: "none", + }); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: CALL_ITEM })); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 1, item: MESSAGE_ITEM })); + rewrite(terminalBlock(SPARSE_TERMINAL)); + + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); From 3a0718c81c109040f44293939b44500befab3fba Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 08:22:48 +0900 Subject: [PATCH 54/87] fix(runtime): one resend budget across composed recovery legs (#5378) * fix(runtime): hold the ambiguous-resend grant to the whole logical request The grant was one counter per request but its CEILING was read from whichever leg happened to ask. A leg builds its allowance from route.provider, and credential rotation, OAuth refresh, transport resolution and each combo target reassign that row inside one request, so a request that had already spent the single replacement a strict row granted bought a second one as soon as a more permissive row asked. How many times one turn could be re-sent depended on the order the rows asked in. The request now keeps the smallest ceiling any leg has presented. A zero or nonsense ceiling still refuses on its own and leaves the request's alone: that is a caller which cannot state a grant, not an operator narrowing this request, and a leg with no policy is refused before it ever claims. The second counter was in the bridge. A scope derived from a budget this factory did not build kept its own claim count, so two derived scopes -- or a scope beside the parent it came from -- each replaced an unknown-state send once. Pending external bookings and the spend observer genuinely cannot cross that boundary because they are private to the factory, but the grant can: claimAmbiguousResend is public on the parent, so the bridge asks it and fails closed when it is absent. * test(lib): watch physical and duplicate sends apart across composed recovery legs The gate test asks one question at a time and the budget test makes one claim at a time. Neither can see a request that reaches upstream through five layers where every layer is correct alone and the request as a whole still duplicates a turn. This walks one logical request through the whole chain -- first send, pre-header reset, the authorised replacement, a disconnect after the head with only control events, a combo candidate's derived scope, a credential refresh, the 429 leg, and the final-recovery reserve -- and counts two numbers separately: every send that left, and the subset carrying a turn the origin may already have run. Four sends is within budget whether one of them replaces a possibly-executed turn or three do, which is why the second number needs its own counter. The chain ends at the request's send ceiling with the grant spent exactly once, and the two counts are reconciled against the request's own ledger. The last leg moves to a row the operator tuned higher, which is the case the per-leg ceiling used to widen. Numbers are derived from resetReplayPolicyFor and the budget policy rather than written out, so the test states the relationship rather than today's values. The nonsense-ceiling case in execution-budget-permits.test.ts still holds as written. The case beside it changes: raising the ceiling no longer releases the difference, because the leg presenting the higher number is not the request. * docs(structure): state that the resend ceiling belongs to the request INV-RESEND-02 said one logical request holds one grant however many stages ask, which was true of the count and not of the ceiling. Both documents now say where the number comes from and why a leg cannot raise it, and record that a derived scope draws on the same grant even when its parent is a hand-built view. --- scripts/test-layout/layout.json | 3 +- src/lib/request-execution-budget.ts | 61 +++- structure/overview.md | 5 +- structure/transports/responses.md | 10 + tests/fixtures/test-layout-expected.json | 3 +- .../lib/ambiguous-resend-composition.test.ts | 291 ++++++++++++++++++ tests/lib/execution-budget-permits.test.ts | 8 +- 7 files changed, 366 insertions(+), 15 deletions(-) create mode 100644 tests/lib/ambiguous-resend-composition.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 81035d9b1e7..86d832fb3c8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1628,7 +1628,8 @@ "management-provider-reset-replay.test.ts": "server", "responses-reset-replay.test.ts": "responses", "ambiguous-resend-gate.test.ts": "lib", - "docs-developer-role-policy.test.ts": "ci-workflows" + "docs-developer-role-policy.test.ts": "ci-workflows", + "ambiguous-resend-composition.test.ts": "lib" }, "migrated": [ "adapters", diff --git a/src/lib/request-execution-budget.ts b/src/lib/request-execution-budget.ts index f6cd5042366..429c0bc1e9d 100644 --- a/src/lib/request-execution-budget.ts +++ b/src/lib/request-execution-budget.ts @@ -176,6 +176,13 @@ export interface RequestExecutionBudget extends TransientSendBudget { * twice. It is NOT a send budget: an authorised replacement still has to fit inside * `remainingBaseSends` like every other send. * + * `limit` is the ceiling the ASKING leg is authorised to present, and the request keeps the + * smallest one any leg has presented. A leg reads it from `route.provider`, which credential + * rotation, OAuth refresh, transport resolution and a combo target all reassign mid-request, + * so a per-call ceiling meant the number of duplicate inferences a request could make + * depended on which row happened to ask last: a row granting one, then a row granting two, + * bought a second replacement of a turn that may already have run. + * * Optional so a hand-written stub that satisfies the shape test keeps typechecking; a caller * that cannot reach it has no operator override, which is the fail-closed answer. */ @@ -208,16 +215,48 @@ interface SharedSendLedger { spent: number; pendingExternalSends: number; /** - * Replacements this logical request has already spent on ambiguous failures. Beside `spent` + * Spend one of this logical request's replacements for an ambiguous failure. Beside `spent` * for the same reason `pendingExternalSends` is: a derived scope that shared one without the * other would hand the request a second grant. + * + * A function rather than the raw count, because the count is not the whole state. The + * ceiling belongs to the request too, and a bridged scope has no counter of its own to keep + * it in -- it has to ask whoever holds the request's grant. */ - ambiguousResendsClaimed: number; + claimAmbiguousResend(limit: number): boolean; readonly observer?: RequestSendObserver; } const sharedSendLedgers = new WeakMap(); +/** + * One logical request's replacement grant: how many it has spent, and the ceiling it is held + * to. + * + * The ceiling is the SMALLEST any leg has presented rather than whatever the current leg + * presents. Each leg reads its number from the provider row it is running against, and that + * row changes inside one request -- credential rotation, OAuth refresh, transport resolution + * and each combo target reassign it. Taking the asking leg's number let a request that had + * already spent the one replacement a strict row granted buy another as soon as a more + * permissive row asked, which is a second duplicate inference of one turn. + */ +function createAmbiguousResendGrant(): (limit: number) => boolean { + let claimed = 0; + let ceiling: number | undefined; + return (limit: number): boolean => { + const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0; + // A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a + // caller that cannot state a grant, not an operator narrowing this request: a leg with no + // policy is refused before it ever claims, so binding the request to a malformed number + // would only let such a caller cancel a grant an opted-in row really made. + if (presented <= 0) return false; + ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented); + if (claimed >= ceiling) return false; + claimed += 1; + return true; + }; +} + function createRequestExecutionBudgetWithLedger( policy: RequestExecutionBudgetPolicy, logicalRequestId: string | undefined, @@ -261,10 +300,7 @@ function createRequestExecutionBudgetWithLedger( return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent)); }, claimAmbiguousResend(limit: number): boolean { - const ceiling = Number.isFinite(limit) ? Math.trunc(limit) : 0; - if (counter.ambiguousResendsClaimed >= ceiling) return false; - counter.ambiguousResendsClaimed += 1; - return true; + return counter.claimAmbiguousResend(limit); }, reserveDispatch(intent: DispatchIntent): DispatchDecision { if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" }; @@ -363,7 +399,7 @@ export function createRequestExecutionBudget( return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, { spent: 0, pendingExternalSends: 0, - ambiguousResendsClaimed: 0, + claimAmbiguousResend: createAmbiguousResendGrant(), ...(observer ? { observer } : {}), }); } @@ -398,14 +434,19 @@ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger { const existing = sharedSendLedgers.get(parent); if (existing) return existing; let pendingExternalSends = 0; - let ambiguousResendsClaimed = 0; return { get spent(): number { return parent.used; }, set spent(next: number) { parent.used = next; }, get pendingExternalSends(): number { return pendingExternalSends; }, set pendingExternalSends(next: number) { pendingExternalSends = next; }, - get ambiguousResendsClaimed(): number { return ambiguousResendsClaimed; }, - set ambiguousResendsClaimed(next: number) { ambiguousResendsClaimed = next; }, + // Asked of the parent rather than counted here. A local counter is a SECOND grant: two + // scopes derived from one bridged parent, or one scope beside the parent it was derived + // from, each replaced an unknown-state send once. Pending bookings and the durable-spend + // observer genuinely cannot cross this boundary because they are private to the factory, + // but the grant can -- `claimAmbiguousResend` is public on the parent. A parent that does + // not implement it grants nothing, which is the fail-closed answer for a send whose + // upstream state is unknown. + claimAmbiguousResend: (limit: number): boolean => parent.claimAmbiguousResend?.(limit) === true, }; } diff --git a/structure/overview.md b/structure/overview.md index b98c44316d8..55b6f89b21c 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -158,7 +158,10 @@ still cover the rule, which is a judgement only review makes. that override is applied, it claims the grant at the moment it authorises rather than earlier, and a stage the caller observed something at refuses without spending it. The grant never widens a send budget: an authorised replacement still has to fit the allowance the leg already - had. + had. The ceiling is the request's, not the asking leg's: a leg reads its number from the + provider row it is currently running against, rotation, refresh, transport resolution and each + combo target reassign that row, so the request keeps the smallest ceiling any leg presented and + a more permissive row arriving later buys nothing. Enforced by `tests/lib/ambiguous-resend-gate.test.ts`. - **INV-CHAT-01** — One developer-role policy governs the translated Chat wire and every document that describes it. `foldDeveloperRoleToSystem` unset and `true` send `system`, `false` sends diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 11b9fed453f..bd937a33975 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -908,6 +908,16 @@ counter rather than holding a second. A replacement never widens a send budget: fit inside the allowance the leg already had, and it is charged to the same counter every other send goes through. +The number of replacements is the request's as well. A leg reads it from `route.provider`, which +credential rotation, OAuth refresh, transport resolution and each combo target reassign inside one +request, so the grant is held to the smallest ceiling any leg has presented rather than to +whatever the asking leg presents. Otherwise a request that had already spent the one replacement a +strict row granted bought a second duplicate inference as soon as a more permissive row asked, and +how many times one turn could be re-sent depended on the order the rows happened to ask in. A +derived scope draws on the same grant even when its parent is a hand-built view rather than a +factory budget: the claim is public on the parent, so unlike a pending external booking there is +nothing private that forces a second counter. + A committed or futile failure refuses without touching the grant, so a turn that already emitted output cannot drain the replacement a later ambiguous reset would have been entitled to. The cause is derived from the `AttemptRecoveryKind` the send will be recorded as, which is what diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 01a0b923f0f..4fa161fe370 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1460,5 +1460,6 @@ "management-provider-reset-replay.test.ts": "server", "responses-reset-replay.test.ts": "responses", "ambiguous-resend-gate.test.ts": "lib", - "docs-developer-role-policy.test.ts": "ci-workflows" + "docs-developer-role-policy.test.ts": "ci-workflows", + "ambiguous-resend-composition.test.ts": "lib" } diff --git a/tests/lib/ambiguous-resend-composition.test.ts b/tests/lib/ambiguous-resend-composition.test.ts new file mode 100644 index 00000000000..f9dd56e9bec --- /dev/null +++ b/tests/lib/ambiguous-resend-composition.test.ts @@ -0,0 +1,291 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + CODEX_TEXT_GUARDED_BUDGET_POLICY, + createRequestExecutionBudget, + deriveRequestExecutionBudget, + type RequestExecutionBudget, + type SendClass, +} from "../../src/lib/request-execution-budget"; +import { authorizeResendForRecovery, type ResendDecision } from "../../src/lib/request-resend-gate"; +import type { RequestFailureStage } from "../../src/lib/request-failure-model"; +import { + fetchWithResetRetry, + isNonReplayableResponse, + refetchAfterProtocolSafeReset, + TRANSIENT_RETRY_MAX_ATTEMPTS, +} from "../../src/lib/upstream-retry"; +import { ambiguousResendAllowanceFor } from "../../src/server/responses/reset-replay"; +import { resetReplayPolicyFor } from "../../src/providers/key-failover"; +import { repoPath } from "../helpers/repo-root"; +import type { OcxProviderConfig } from "../../src/types"; + +/* + * Holds INV-RESEND-02 from structure/overview.md across a COMPOSITION of legs. + * + * tests/lib/ambiguous-resend-gate.test.ts asks the gate one question at a time and + * tests/lib/execution-budget-permits.test.ts asks the budget one claim at a time. Neither can + * see what this file is about: a request that reaches upstream through five layers, where + * every layer is correct on its own and the request as a whole still duplicates a turn. + */ + +type ProviderRow = Pick; + +/** The bare opt-in, and whatever one of it means to the operator today. */ +const OPTED_IN: ProviderRow = { retryOnReset: {} }; +const GRANT = resetReplayPolicyFor(OPTED_IN)?.replacements ?? 0; +/** + * A row an operator tuned above the bare opt-in. Derived rather than written as a number: what + * matters is that it grants MORE than the row this request started on, not that it grants two. + */ +const MORE_PERMISSIVE: ProviderRow = { retryOnReset: { replacements: GRANT + 1 } }; + +function reset(): Error { + const err = new Error("The socket connection was closed unexpectedly."); + (err as Error & { code: string }).code = "ECONNRESET"; + return err; +} + +const warnSpies: Array> = []; +function silenceWarn(): void { + warnSpies.push(spyOn(console, "warn").mockImplementation(() => {})); +} +afterEach(() => { + for (const spy of warnSpies.splice(0)) spy.mockRestore(); +}); + +/** + * One logical request, watched through the two numbers that have to be watched apart. + * + * `physicalSends` is every send that left this proxy. `ambiguousSends` is the subset that carried a + * turn the origin may already have run -- the number an operator consents to when they set + * `retryOnReset`, and the only one that counts duplicate inferences. A composition can hold the + * first bound and break the second: four sends is within budget whether one of them replaces a + * possibly-executed turn or three of them do. + */ +function oneLogicalRequest() { + const budget = createRequestExecutionBudget(CODEX_TEXT_GUARDED_BUDGET_POLICY); + const counts = { physical: 0, ambiguous: 0 }; + + /** Every leg asks the gate, and the gate is what tells us the send duplicates a turn. */ + const authorize = (stage: RequestFailureStage, row: ProviderRow): ResendDecision => { + const decision = authorizeResendForRecovery( + stage, + "connection-reset", + // The allowance a leg builds from the provider row it is CURRENTLY running against, + // which rotation, refresh, transport resolution and a combo target each reassign. + ambiguousResendAllowanceFor(row, () => true, limit => budget.claimAmbiguousResend?.(limit) === true), + ); + if (decision.allowed && decision.spentOperatorAllowance) counts.ambiguous += 1; + return decision; + }; + + const dispatcher = (outcomes: Array) => { + let index = 0; + return async (): Promise => { + counts.physical += 1; + const next = outcomes[index] ?? outcomes[outcomes.length - 1]; + index += 1; + if (next instanceof Error) throw next; + return next ?? new Response("answer"); + }; + }; + + /** + * What src/server/responses/request-send-budget.ts hands a recovery leg: the base allowance + * while it lasts, then the single final-recovery reserve, and nothing after that. + */ + const recoveryAttempts = (sendClass: SendClass, targetKey: string): number => { + const base = budget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS); + if (base > 0) return base; + const decision = budget.reserveDispatch({ sendClass, targetKey, countedExternally: true }); + return decision.allowed ? 1 : 0; + }; + + return { + budget, + get physicalSends(): number { return counts.physical; }, + get ambiguousSends(): number { return counts.ambiguous; }, + recoveryAttempts, + /** A leg that fails before any response head, through the real reset ladder. */ + preHeader: (outcomes: Array, row: ProviderRow, attempts?: number): Promise => + fetchWithResetRetry(dispatcher(outcomes), { + attempts: attempts ?? budget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS), + onSendsConsumed: sends => { budget.used += sends; }, + claimAmbiguousResend: () => authorize("pre-header", row).allowed, + }), + /** A stream that died after the head while carrying only control events. */ + postHeader: (outcomes: Array, row: ProviderRow): Promise => { + const send = dispatcher(outcomes); + return refetchAfterProtocolSafeReset( + async () => { const response = await send(); budget.used += 1; return response; }, + reset(), + { + attempts: budget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS), + authorize: () => authorize("protocol-prelude", row).allowed, + }, + ); + }, + }; +} + +describe("one resend budget across composed recovery legs", () => { + test("the whole chain spends the grant once, whatever each leg was separately entitled to", async () => { + silenceWarn(); + const request = oneLogicalRequest(); + + // 1. The first send dies before any head. The operator's replacement is spent here, and + // the replacement answers. + const first = await request.preHeader([reset(), new Response("first answer")], OPTED_IN); + expect(await first.text()).toBe("first answer"); + expect(request.physicalSends).toBe(2); + expect(request.ambiguousSends).toBe(GRANT); + + // 2. That stream then dies after the head with only control events on it -- the other side + // of the same question. Nothing was observed, so this stage would be entitled to a + // replacement of its own; the request has none left, and no send leaves. + const secondChance = await request.postHeader([new Response("second answer")], OPTED_IN); + expect(secondChance).toBeNull(); + expect(request.physicalSends).toBe(2); + + // 3. A combo candidate derives its own scope, with its own reserve and target ledgers. The + // grant is not one of them, at either row's ceiling. + const comboChild = deriveRequestExecutionBudget(request.budget, CODEX_TEXT_GUARDED_BUDGET_POLICY); + expect(comboChild.claimAmbiguousResend?.(GRANT)).toBe(false); + expect(comboChild.claimAmbiguousResend?.(MORE_PERMISSIVE.retryOnReset?.replacements ?? 0)).toBe(false); + + // 4. A credential refresh replays the turn on a new token and resets before the head. The + // leg settles as the refusal rather than sending again, and the client is handed a + // response no retry policy will replay. + const afterRefresh = await request.preHeader([reset(), new Response("duplicate")], OPTED_IN); + expect(isNonReplayableResponse(afterRefresh)).toBe(true); + expect(request.physicalSends).toBe(3); + + // 5. The 429 leg finds the base allowance gone. The grant is never consulted, which is the + // ordering that keeps a spent send budget from draining it. + expect(request.budget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS)).toBe(0); + + // 6. The last send this request is entitled to is the single final-recovery reserve, and + // the account it moves to is a row the operator tuned HIGHER. A ceiling read from the + // asking leg let that row buy a second duplicate inference on the way out; the ceiling + // is the smallest any leg presented, so it buys nothing. + const attempts = request.recoveryAttempts("account-failover", "other-account"); + expect(attempts).toBe(1); + const lastSend = await request.preHeader([reset(), new Response("duplicate")], MORE_PERMISSIVE, attempts); + expect(isNonReplayableResponse(lastSend)).toBe(true); + + expect(request.ambiguousSends).toBe(GRANT); + expect(request.physicalSends).toBe(CODEX_TEXT_GUARDED_BUDGET_POLICY.maxTotalModelSends); + // The two numbers describe the same sends, so the request's own ledger has to agree with + // what the transport actually dispatched. A leg that books a send it never made, or makes + // one it never booked, shows up here rather than as a ceiling that quietly stopped firing. + expect(request.budget.used).toBe(request.physicalSends); + }); + + test("a later leg on a more permissive row cannot raise this request's ceiling", () => { + const budget = createRequestExecutionBudget(CODEX_TEXT_GUARDED_BUDGET_POLICY); + const strict = resetReplayPolicyFor(OPTED_IN)?.replacements ?? 0; + const permissive = resetReplayPolicyFor(MORE_PERMISSIVE)?.replacements ?? 0; + expect(permissive).toBeGreaterThan(strict); + + // A leg that cannot state a grant refuses on its own terms and does not narrow the + // request either: a malformed ceiling must not cancel what an opted-in row really gave. + expect(budget.claimAmbiguousResend?.(0)).toBe(false); + + for (let spent = 0; spent < strict; spent += 1) { + expect(budget.claimAmbiguousResend?.(strict)).toBe(true); + } + // route.provider is reassigned by rotation, refresh and transport resolution, so the row a + // leg reads its ceiling from is not necessarily the row the request started on. + expect(budget.claimAmbiguousResend?.(permissive)).toBe(false); + }); + + test("a request that starts permissive is still held to the strictest row that asks", () => { + const budget = createRequestExecutionBudget(CODEX_TEXT_GUARDED_BUDGET_POLICY); + const strict = resetReplayPolicyFor(OPTED_IN)?.replacements ?? 0; + const permissive = resetReplayPolicyFor(MORE_PERMISSIVE)?.replacements ?? 0; + + for (let spent = 0; spent < strict; spent += 1) { + expect(budget.claimAmbiguousResend?.(permissive)).toBe(true); + } + // The permissive row had more to give, and the strict row is what this request is held to + // from the moment it asks -- the direction the pre-existing ordering already failed closed. + expect(budget.claimAmbiguousResend?.(strict)).toBe(false); + expect(budget.claimAmbiguousResend?.(permissive)).toBe(false); + }); + + test("a scope derived from a budget this factory did not build shares the request's grant", () => { + // The bridge exists because `isRequestExecutionBudget` is a shape test: a hand-built view + // reaches derivation, and refusing it would turn a routing request into a 500. What it may + // not do is hand that request a second grant -- `claimAmbiguousResend` is public on the + // parent, so unlike a pending booking there is nothing private stopping it being asked. + const owner = createRequestExecutionBudget(CODEX_TEXT_GUARDED_BUDGET_POLICY); + const view: RequestExecutionBudget = { + get used(): number { return owner.used; }, + set used(next: number) { owner.used = next; }, + logicalRequestId: owner.logicalRequestId, + policyVersion: owner.policyVersion, + policy: owner.policy, + get reserveSpent(): boolean { return owner.reserveSpent; }, + get alternateTargetSends(): number { return owner.alternateTargetSends; }, + get targetTransitions(): number { return owner.targetTransitions; }, + get lastTargetKey(): string | undefined { return owner.lastTargetKey; }, + remainingBaseSends: (cap: number): number => owner.remainingBaseSends(cap), + claimAmbiguousResend: (limit: number): boolean => owner.claimAmbiguousResend?.(limit) === true, + reserveDispatch: intent => owner.reserveDispatch(intent), + }; + + const first = deriveRequestExecutionBudget(view, CODEX_TEXT_GUARDED_BUDGET_POLICY); + const second = deriveRequestExecutionBudget(view, CODEX_TEXT_GUARDED_BUDGET_POLICY); + expect(first.claimAmbiguousResend?.(GRANT)).toBe(true); + expect(second.claimAmbiguousResend?.(GRANT)).toBe(false); + expect(view.claimAmbiguousResend?.(GRANT)).toBe(false); + expect(owner.claimAmbiguousResend?.(GRANT)).toBe(false); + }); + + test("a parent that grants nothing cannot be bridged into a grant", () => { + const stub: RequestExecutionBudget = { + used: 0, + logicalRequestId: "stub", + policyVersion: "stub", + policy: CODEX_TEXT_GUARDED_BUDGET_POLICY, + reserveSpent: false, + alternateTargetSends: 0, + targetTransitions: 0, + lastTargetKey: undefined, + remainingBaseSends: () => 0, + reserveDispatch: () => ({ allowed: false, reason: "total-exhausted" }), + }; + // Fail closed: a budget that predates the grant has no operator override to share, and an + // unknown upstream state is not made replayable by the scope that asked. + expect(deriveRequestExecutionBudget(stub, CODEX_TEXT_GUARDED_BUDGET_POLICY).claimAmbiguousResend?.(GRANT)) + .toBe(false); + }); + + test("every post-header replacement in the tree is authorized before it sends", async () => { + // `authorize` is optional on the helper, so this is the property that keeps a call site from + // becoming a resend of a possibly-executed turn that no gate ever weighed. Scanned rather + // than listed: the hazard is the call site nobody remembered to add to a list. + const srcDir = repoPath("src"); + const unauthorized: string[] = []; + let callSites = 0; + for await (const relative of new Bun.Glob("**/*.ts").scan({ cwd: srcDir })) { + const source = readFileSync(join(srcDir, relative), "utf8"); + for (const match of source.matchAll(/refetchAfterProtocolSafeReset\(/g)) { + const start = match.index ?? 0; + // The declaration itself is not a call site, and the import names it without one. + if (/\bfunction\s+$/.test(source.slice(Math.max(0, start - 24), start))) continue; + callSites += 1; + // The options object is the last argument of the call, so the window has to cover the + // whole call expression; these are long ones. + if (!source.slice(start, start + 8000).includes("authorize:")) { + unauthorized.push(relative + ":" + (source.slice(0, start).split("\n").length)); + } + } + } + expect(unauthorized).toEqual([]); + // The scan is only evidence while it still finds the call it was written for. + expect(callSites).toBeGreaterThan(0); + }); +}); diff --git a/tests/lib/execution-budget-permits.test.ts b/tests/lib/execution-budget-permits.test.ts index 9f7f96b0ec8..916304accad 100644 --- a/tests/lib/execution-budget-permits.test.ts +++ b/tests/lib/execution-budget-permits.test.ts @@ -494,9 +494,13 @@ describe("the ambiguous-resend allowance", () => { expect(parent.claimAmbiguousResend?.(1)).toBe(true); expect(child.claimAmbiguousResend?.(1)).toBe(false); expect(parent.claimAmbiguousResend?.(1)).toBe(false); - // Raising the ceiling releases exactly the difference, not a fresh grant. - expect(child.claimAmbiguousResend?.(2)).toBe(true); + // A later leg cannot raise the ceiling, either. Each leg reads its number from the + // provider row it is running against, and that row is reassigned mid-request by rotation, + // refresh, transport resolution and each combo target -- so releasing the difference meant + // the count of duplicate inferences depended on which row happened to ask last. The + // request keeps the smallest ceiling any leg presented. expect(child.claimAmbiguousResend?.(2)).toBe(false); + expect(parent.claimAmbiguousResend?.(2)).toBe(false); }); test("a grant is not a send, and a spent send budget is not a spent grant", () => { From 556b670251aa21c8bab68db698faa52676a1196a Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 08:22:56 +0900 Subject: [PATCH 55/87] fix(transports): answer an ambiguous resend the same way on every HTTP surface (#5377) * fix(transports): answer an ambiguous resend the same way on every HTTP surface The pre-header replay refusal was correct where it was written and absent one wrapper away. The translated Chat surface preserved only the cyber-policy code and model_not_found, took the upstream code only when classifyError had produced none, and then attached the default Retry-After for a retryable 429, so a refusal to resend reached the client as an ordinary rate limit with an instruction to send the turn again. Carry the verdict as a property of the response every surface shares rather than something each one re-derives: retainReplayRefusal and carryReplayRefusal restate the in-process marker at each re-wrap, including the deferred-logging wrapper that silently dropped it. All four formatters now read the marker, or the code a body kept through an intermediate formatter, and never the status -- 429 is exactly what a refusal and a real rate limit have in common. Dropping Retry-After is necessary and not sufficient. The Stainless-generated clients (openai and anthropic, Python and Node) retry 408, 409, 429 and every 5xx from their own table and compute their own backoff when no wait is named, so the status stays 429 (Codex stops there, a 5xx invites four more sends) and every surface also emits x-should-retry: false, which those clients read first. * test(server): count the upstream sends a retrying client causes The acceptance unit for this refusal is not the shape of one response: it is how many times the turn physically reaches upstream when a client with retries enabled is the one deciding. A single fetch cannot see that, because the proxy can answer correctly and the duplicate inference still happens. The case runs the proxy over a socket, counts sends at the upstream boundary and drives all three surfaces with a client that implements the published SDK rule (an explicit x-should-retry first, then 408/409/429/5xx). Its header literals are deliberate: the double stands in for the third party and has to keep believing what those clients believe. A rate-limit control shows the same client resending, so "one send" is a property of the answer rather than of the double. * docs(transports): record the one replay-refusal client contract Both documents stated the refusal carries no Retry-After and stopped there, which is the half of the policy that does not survive contact with a retrying client. Name the fourth writer, the property that now travels with the response, and the suppression header, and point the invariant at the count that holds it. --- .../docs/reference/configuration/server.md | 12 +- scripts/test-layout/layout.json | 1 + src/bridge/errors.ts | 10 +- src/lib/upstream-retry.ts | 58 +++++- src/server/chat-completions.ts | 42 +++- src/server/chat-native.ts | 18 +- src/server/relay.ts | 8 +- src/server/responses/passthrough-error.ts | 35 +++- structure/transports/responses.md | 27 +++ tests/fixtures/test-layout-expected.json | 1 + tests/server/replay-refusal-parity.test.ts | 194 ++++++++++++++++++ 11 files changed, 380 insertions(+), 26 deletions(-) create mode 100644 tests/server/replay-refusal-parity.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 78df10f7513..92aae00980f 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -64,10 +64,14 @@ arrives, the proxy cannot tell whether the model already processed the request, to send it again and answers HTTP 429 with `upstream_reset_replay_refused`. The status is deliberate: a 5xx here is an instruction to most clients, including Codex, to send the whole turn again, which is the duplicate the refusal exists to prevent. No `Retry-After` is -attached, and the proxy performs no key rotation, account failover or same-target replay on -it, nor does it record the refusal as rate-limit or quota evidence against the credential it -was holding. Tool-call side requests such as vision and web search are replayed normally, because -repeating them cannot duplicate a turn. +attached, and the response carries `x-should-retry: false`, which the official OpenAI and +Anthropic SDKs read before their own status rules — without it those clients retry a 429 on +their own schedule and resend the turn anyway. The answer is identical on `/v1/responses` and +on `/v1/chat/completions`, whether the request is forwarded natively or translated. The proxy +performs no key rotation, account failover or same-target replay on it, nor does it record the +refusal as rate-limit or quota evidence against the credential it was holding. Tool-call side +requests such as vision and web search are replayed normally, because repeating them cannot +duplicate a turn. A native Responses provider can opt into replacing that send with [`retryOnReset`](providers.md#provider-entries-ocxproviderconfig). The same grant covers the diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 86d832fb3c8..d1f26dcba77 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1228,6 +1228,7 @@ "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", "remove-tree-helper.test.ts": "lib", + "replay-refusal-parity.test.ts": "server", "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", "request-evidence.test.ts": "usage", diff --git a/src/bridge/errors.ts b/src/bridge/errors.ts index aa270195792..e39aa1f4b45 100644 --- a/src/bridge/errors.ts +++ b/src/bridge/errors.ts @@ -1,9 +1,10 @@ import { + applyReplayRefusalClientHeaders, isNonReplayableUpstreamCode, isReplayRefusalCode, - markReplayRefusalResponse, markResponseNonReplayable, REPLAY_REFUSED_STATUS, + retainReplayRefusal, } from "../lib/upstream-retry"; import { adapterFailureFromMessage, @@ -45,6 +46,11 @@ export function formatErrorResponse( && retryAfter.length <= 128) { headers.set("Retry-After", retryAfter); } + // The refusal's client policy, restated here for the same reason its status is: this + // formatter is the last thing several adapter and combo paths touch before the client, + // and no wait of its own does not stop a client that retries every 429 by default. + const refusal = isReplayRefusalCode(error.code) && replayBlocked; + if (refusal) applyReplayRefusalClientHeaders(headers); const response = new Response(JSON.stringify({ error }), { status: finalStatus, headers, @@ -53,6 +59,6 @@ export function formatErrorResponse( // Re-wrapping is where the refusal loses its provenance: combo failure consumption parses // the JSON and builds a new Response, and the code alone does not tell a later quota // recorder that no upstream produced this status. Carry the narrower marker across too. - if (replayBlocked && isReplayRefusalCode(error.code)) markReplayRefusalResponse(response); + if (refusal) retainReplayRefusal(response); return response; } diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 30f17a7b5d8..23d1f0c4ffd 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -98,6 +98,55 @@ export function isReplayRefusalCode(code: unknown): boolean { /** Client-facing status for {@link UPSTREAM_RESET_REPLAY_REFUSED_CODE}. */ export const REPLAY_REFUSED_STATUS = 429; +/** + * The header every surface attaches to a replay refusal, and its only accepted value. + * + * Dropping `Retry-After` is necessary and not sufficient. The Stainless-generated clients -- + * `openai` and `anthropic` for both Python and Node, which is what most callers of this proxy + * actually are -- decide from a status table (408, 409, 429 and every 5xx) and compute their own + * backoff when no wait is named, so a 429 with no header is still resent. `x-should-retry` is + * the one signal each of them reads BEFORE that table, and `"false"` is the exact string they + * compare against. + */ +export const REPLAY_REFUSAL_NO_RETRY_HEADER = "x-should-retry"; +export const REPLAY_REFUSAL_NO_RETRY_VALUE = "false"; + +/** Spreadable form for the surfaces that build their headers as an object literal. */ +export const REPLAY_REFUSAL_CLIENT_HEADERS: Readonly> = Object.freeze({ + [REPLAY_REFUSAL_NO_RETRY_HEADER]: REPLAY_REFUSAL_NO_RETRY_VALUE, +}); + +/** + * Apply the one client-facing retry policy a refusal carries: no wait, and no automatic resend. + * + * Kept as a single function rather than two rules each surface repeats, because the two halves + * are only correct together -- a surface that removed the wait but not the suppression still + * hands a retrying client a turn it may already have run. + */ +export function applyReplayRefusalClientHeaders(headers: Headers): void { + headers.delete("retry-after"); + headers.set(REPLAY_REFUSAL_NO_RETRY_HEADER, REPLAY_REFUSAL_NO_RETRY_VALUE); +} + +/** + * Mark a response that re-wraps a refusal as the same refusal. + * + * The verdict has to be a property of the result the surfaces pass around, because the thing it + * would otherwise be read from is the status, and 429 is exactly what a refusal and a real rate + * limit have in common. Every formatter between the helper that made the refusal and the client + * builds a new Response, so each of them restates the verdict rather than dropping it. + */ +export function retainReplayRefusal(response: T): T { + markResponseNonReplayable(response); + markReplayRefusalResponse(response); + return response; +} + +/** Carry the verdict from a response onto the one that replaces it. */ +export function carryReplayRefusal(source: Response, rewrapped: T): T { + return isReplayRefusalResponse(source) ? retainReplayRefusal(rewrapped) : rewrapped; +} + // 1 initial + 2 retries: the pool may hold more than one stale socket. const RESET_RETRY_MAX_ATTEMPTS = 3; const RESET_RETRY_BASE_DELAY_MS = 150; @@ -521,10 +570,11 @@ export function replayRefusalResponse(): Response { type: "upstream_error", code: UPSTREAM_RESET_REPLAY_REFUSED_CODE, message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.", - } }), { status: REPLAY_REFUSED_STATUS, headers: { "content-type": "application/json" } }); - markResponseNonReplayable(response); - markReplayRefusalResponse(response); - return response; + } }), { + status: REPLAY_REFUSED_STATUS, + headers: { "content-type": "application/json", ...REPLAY_REFUSAL_CLIENT_HEADERS }, + }); + return retainReplayRefusal(response); } /** diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 4f4209d11c0..3cb6c9a3798 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -22,6 +22,15 @@ import { import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; +import { + applyReplayRefusalClientHeaders, + isReplayRefusalCode, + isReplayRefusalResponse, + REPLAY_REFUSAL_CLIENT_HEADERS, + REPLAY_REFUSED_STATUS, + retainReplayRefusal, + UPSTREAM_RESET_REPLAY_REFUSED_CODE, +} from "../lib/upstream-retry"; import { estimateTokens } from "../lib/token-estimate"; import { captureRouteStaticPolicy, NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; import { evidenceFromBody } from "../routing/request-evidence"; @@ -419,9 +428,19 @@ async function handleChatCompletionsWithBudget( : "invalid_request_error"), message, ); + // The same verdict the native Chat surface reads, from the same two places: the response + // this wrapper still holds, and the code a body kept through an intermediate formatter. + // Not the status -- a refusal and a real rate limit are both 429, which is the whole + // reason this surface used to report one as the other. + const replayRefusal = isReplayRefusalResponse(upstream) || isReplayRefusalCode(upstreamCode); if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(upstreamType); + } else if (replayRefusal) { + // 429 classifies as a rate limit, which already carries a code, so the empty-code branch + // below could never restore this one -- the translated client was told the provider + // throttled the turn, and handed a two-second wait to send it again. + classified.code = UPSTREAM_RESET_REPLAY_REFUSED_CODE; } else if (upstreamCode === "model_not_found") { // Structured model_not_found must win over classifyError's generic remaps. classified.code = "model_not_found"; @@ -429,8 +448,10 @@ async function handleChatCompletionsWithBudget( } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } - const status = isCyberPolicyCode(classified.code) ? 400 : upstream.status; - const retryAfter = isCyberPolicyCode(classified.code) + const status = isCyberPolicyCode(classified.code) ? 400 + : replayRefusal ? REPLAY_REFUSED_STATUS + : upstream.status; + const retryAfter = isCyberPolicyCode(classified.code) || replayRefusal ? undefined : resolveClientRetryAfter({ status: upstream.status, @@ -449,9 +470,12 @@ async function handleChatCompletionsWithBudget( headers: { "Content-Type": "application/json", ...(retryAfter ? { "Retry-After": retryAfter } : {}), + ...(replayRefusal ? REPLAY_REFUSAL_CLIENT_HEADERS : {}), }, }); + if (replayRefusal) retainReplayRefusal(rewritten); return logIds + // Deferred logging re-wraps this response and carries the verdict with it. ? responseWithDeferredRequestLog(rewritten, logIds.requestId, logIds.start, logCtx) : rewritten; } @@ -518,11 +542,25 @@ async function handleChatCompletionsWithBudget( } else if (isCyberPolicyCode(error?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(error?.type); + } else if (isReplayRefusalCode(error?.code)) { + // The refusal can also arrive as a failed Responses envelope rather than a non-2xx. + // Reporting that as the 502 below would invite the four resends the refusal prevents. + classified.code = UPSTREAM_RESET_REPLAY_REFUSED_CODE; } else if (error?.code === "model_not_found") { // Same deliberate preserve as the non-OK path: structured code beats generic classify. classified.code = "model_not_found"; classified.type = "invalid_request_error"; } + if (isReplayRefusalCode(classified.code)) { + const refusal = chatCompletionsErrorResponse( + REPLAY_REFUSED_STATUS, message, classified.type, classified.code, + ); + const headers = new Headers(refusal.headers); + applyReplayRefusalClientHeaders(headers); + return finishJson(retainReplayRefusal( + new Response(refusal.body, { status: refusal.status, headers }), + )); + } return finishJson(chatCompletionsErrorResponse( classified.code === "translation_buffer_limit" ? 502 diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index c77b6fda592..71ef323ebf5 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -29,6 +29,9 @@ import { isReplayRefusalCode, isReplayRefusalResponse, prepareSameTarget429Wait, + REPLAY_REFUSAL_CLIENT_HEADERS, + REPLAY_REFUSED_STATUS, + retainReplayRefusal, type UpstreamSendRecovery, UPSTREAM_RESET_REPLAY_REFUSED_CODE, } from "../lib/upstream-retry"; @@ -497,10 +500,13 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio : response.status >= 500 ? "server_error" : "invalid_request_error"), clientMessage, ); + // The verdict is read once, from the response that carries it and from the code a + // re-wrapped body kept -- never from the status, which a real rate limit shares. + const replayRefusal = isReplayRefusalResponse(response) || isReplayRefusalCode(upstreamCode); if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(upstreamType); - } else if (isReplayRefusalResponse(response) || isReplayRefusalCode(upstreamCode)) { + } else if (replayRefusal) { // 429 classifies as a rate limit and a rate limit already carries a code, so the branch // below -- which only fills an EMPTY code -- could never restore this one. Without it the // client is told the provider throttled the turn, when what happened is that this proxy @@ -512,11 +518,13 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } - const status = isCyberPolicyCode(classified.code) ? 400 : response.status; + const status = isCyberPolicyCode(classified.code) ? 400 + : replayRefusal ? REPLAY_REFUSED_STATUS + : response.status; // A refusal this proxy made has no wait to report. Synthesizing one here would hand the // client the default two-second retry for a rate limit that never happened, which is the // duplicate send the refusal exists to prevent. - const retryAfter = isCyberPolicyCode(classified.code) || isReplayRefusalCode(classified.code) + const retryAfter = isCyberPolicyCode(classified.code) || replayRefusal ? undefined : resolveClientRetryAfter({ status: response.status, @@ -524,13 +532,15 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio upstreamRetryAfter: response.headers.get("retry-after"), }); finishLog(status, classified.message); - return new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), { + const rewritten = new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), { status, headers: { "Content-Type": "application/json", ...(retryAfter ? { "Retry-After": retryAfter } : {}), + ...(replayRefusal ? REPLAY_REFUSAL_CLIENT_HEADERS : {}), }, }); + return replayRefusal ? retainReplayRefusal(rewritten) : rewritten; } const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; diff --git a/src/server/relay.ts b/src/server/relay.ts index 4e2f8bbd67a..1bbb9fd72c7 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -12,6 +12,7 @@ import { } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; +import { carryReplayRefusal } from "../lib/upstream-retry"; import { isUsageDebugEnabled } from "../usage/debug"; import { addRequestLog, @@ -833,11 +834,14 @@ export function responseWithDeferredRequestLog( }, addLog); }, }); - return new Response(body, { + // Logging re-wraps the response, and an in-process verdict does not survive a re-wrap on + // its own. A replay refusal that lost it here would read to a later quota recorder or + // Retry-After synthesizer as a 429 some upstream produced. + return carryReplayRefusal(response, new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers, - }); + })); } if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) { logCtx.usageDebugBodyKind = response.body ? "other" : "none"; diff --git a/src/server/responses/passthrough-error.ts b/src/server/responses/passthrough-error.ts index eee33c02a96..7a993255ee3 100644 --- a/src/server/responses/passthrough-error.ts +++ b/src/server/responses/passthrough-error.ts @@ -1,6 +1,11 @@ import { formatErrorResponse } from "../../bridge"; import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors"; -import { isReplayRefusalCode, UPSTREAM_RESET_REPLAY_REFUSED_CODE } from "../../lib/upstream-retry"; +import { + applyReplayRefusalClientHeaders, + isReplayRefusalCode, + retainReplayRefusal, + UPSTREAM_RESET_REPLAY_REFUSED_CODE, +} from "../../lib/upstream-retry"; import { resolveClientRetryAfter, validateClientRetryAfterHeader, @@ -62,6 +67,10 @@ function isReplayRefusalBody(body: string): boolean { * - a replay refusal this proxy wrote gets none and keeps none: the whole point of the * refusal is that the turn may already be running, and the synthetic default for a * retryable 429 is a direct instruction to the client to send it a second time + * + * A refusal also leaves with the shared no-retry header and the in-process marker, so the + * verdict survives this re-wrap as a property of the response rather than as a status a later + * reader would have to guess from. */ export function formatPassthroughUpstreamError( status: number, @@ -84,11 +93,10 @@ export function formatPassthroughUpstreamError( const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined; const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now); const cyberPolicyFailure = isCyberPolicyBody(trimmed); + const replayRefusal = options?.replayRefusal === true || isReplayRefusalBody(trimmed); // Two different reasons to answer with no wait at all, handled the same way: a hard policy // block will not become servable, and a refusal we made was never a rate limit. - const suppressRetryAfter = cyberPolicyFailure - || options?.replayRefusal === true - || isReplayRefusalBody(trimmed); + const suppressRetryAfter = cyberPolicyFailure || replayRefusal; const resolved = suppressRetryAfter ? undefined : resolveClientRetryAfter({ @@ -105,7 +113,9 @@ export function formatPassthroughUpstreamError( && upstreamRetryAfter !== undefined && originalValid === undefined); - if (!needsSet && !needsDelete) { + // A refusal always takes the rewriting path: it has a header to add even when the + // upstream named no wait for it to remove. + if (!needsSet && !needsDelete && !replayRefusal) { return new Response(bodyText, { status, ...(options?.statusText ? { statusText: options.statusText } : {}), @@ -118,21 +128,30 @@ export function formatPassthroughUpstreamError( : new Headers({ "Content-Type": "application/json" }); if (needsSet) headers.set("Retry-After", resolved!); else headers.delete("Retry-After"); - return new Response(bodyText, { + if (replayRefusal) applyReplayRefusalClientHeaders(headers); + const rewritten = new Response(bodyText, { status, ...(options?.statusText ? { statusText: options.statusText } : {}), headers, }); + return replayRefusal ? retainReplayRefusal(rewritten) : rewritten; } const response = formatErrorResponse( status, "upstream_error", `Provider error ${status}: (empty body)`, - resolved !== undefined ? { retryAfter: resolved } : undefined, + // Provenance is all that is left when the bounded read returned nothing display-safe, and + // it is enough: the formatter allowlists this code, restates the refusal status and marks + // the response, so an unreadable refusal reaches the client as the same refusal. + replayRefusal + ? { code: UPSTREAM_RESET_REPLAY_REFUSED_CODE } + : resolved !== undefined ? { retryAfter: resolved } : undefined, ); const headers = new Headers(response.headers); headers.set("Content-Type", "application/json"); if (resolved !== undefined) headers.set("Retry-After", resolved); - return new Response(response.body, { status: response.status, headers }); + if (replayRefusal) applyReplayRefusalClientHeaders(headers); + const wrapped = new Response(response.body, { status: response.status, headers }); + return replayRefusal ? retainReplayRefusal(wrapped) : wrapped; } diff --git a/structure/transports/responses.md b/structure/transports/responses.md index bd937a33975..b4399c97bab 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -1389,6 +1389,33 @@ the default fires on; and `src/server/chat-native.ts` restores the code its own 429 maps to `rate_limit_error`, which already carries a code, so the branch that copies an upstream code could never reach it — and suppresses the same synthetic wait. +**The verdict is a property of the response, and every surface states it the same way.** The +translated Chat wrapper in `src/server/chat-completions.ts` was the fourth writer and the one +that had none of this: it preserved the cyber-policy code and `model_not_found`, took the +upstream code only when `classifyError` had produced none, and then attached the retryable-429 +default. A refusal therefore left the Chat bridge as an ordinary rate limit carrying an +instruction to send the turn again. It now reads the same two things the native surface reads — +`isReplayRefusalResponse` on the response it still holds, and `isReplayRefusalCode` on a body +that came through an intermediate formatter — and never the status, which a refusal and a real +rate limit share. The failed-envelope path in the same function restates it too, so a refusal +arriving as `status: "failed"` is not reported as the 502 a Codex client retries four times. +Because a re-wrap is where the in-process marker is lost, `retainReplayRefusal` and +`carryReplayRefusal` in `src/lib/upstream-retry.ts` are what each formatter calls: +`src/bridge/errors.ts`, `src/server/responses/passthrough-error.ts`, both Chat wrappers, and the +deferred-logging re-wrap in `src/server/relay.ts`. + +**Dropping `Retry-After` is necessary and not sufficient.** The status stays 429 because Codex +stops there and a 5xx invites four more sends, but the Stainless-generated clients — `openai` +and `anthropic`, Python and Node — decide from a status table that includes 429 and compute +their own backoff when no wait is named, so a bare 429 is still resent by most callers of this +proxy. Every surface therefore also emits `x-should-retry: false`, the one signal those clients +read before that table. The refusal is the only code that gets it: the WebSocket post-send +verdicts are genuine upstream observations and keep their existing 502/504 contract. The +acceptance evidence is a count, not a shape — `tests/server/replay-refusal-parity.test.ts` runs +the proxy over a socket, drives all three surfaces with a client that implements the published +SDK rule, and asserts one physical upstream send per logical request, with a rate-limit control +that shows the same client resending. + The existing provider HTTP-status policy and the shared physical-send budget remain independent: zero refuses dispatch, invalid counts fail, and a stopped send is counted once. `src/bridge/errors.ts` retains only the allowlisted non-replayable transport codes, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4fa161fe370..1484512a4c4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1054,6 +1054,7 @@ "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", "remove-tree-helper.test.ts": "lib", + "replay-refusal-parity.test.ts": "server", "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", "request-evidence.test.ts": "usage", diff --git a/tests/server/replay-refusal-parity.test.ts b/tests/server/replay-refusal-parity.test.ts new file mode 100644 index 00000000000..9b8af2cf14c --- /dev/null +++ b/tests/server/replay-refusal-parity.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { + REPLAY_REFUSAL_NO_RETRY_HEADER, + REPLAY_REFUSAL_NO_RETRY_VALUE, + REPLAY_REFUSED_STATUS, + UPSTREAM_RESET_REPLAY_REFUSED_CODE, +} from "../../src/lib/upstream-retry"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig } from "../../src/types"; + +/** + * The acceptance unit for the ambiguous-resend refusal is not the shape of one response: it is + * how many times the turn physically reaches upstream when a real client is allowed to retry. + * A single `fetch` cannot see that, because a client with retries enabled is the thing that + * resends -- the proxy answered correctly and the duplicate inference happened anyway. + * + * So these cases run the proxy over a real socket, count the sends at the upstream boundary, + * and drive it with a client that retries the way the published SDKs do. The three surfaces + * are asserted against one expectation because a client cannot tell them apart: it sent one + * turn and the turn may already have executed, whichever endpoint carried it. + */ +const UPSTREAM_HOST = "replay-refusal-parity.example.test"; +const originalFetch = globalThis.fetch; +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-replay-refusal-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-replay-refusal-")); + process.env.OPENCODEX_HOME = testDir; + globalThis.fetch = originalFetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +/** + * The retry rule of the official clients, written as they write it. + * + * The header name and its two accepted values are deliberately literals here rather than the + * constants this repository exports. This function stands in for the third party: it has to + * keep believing what `openai` and `anthropic` believe -- an explicit verdict first, then the + * 408/409/429/5xx table -- even if our own constant were changed to something no client reads. + */ +function sdkWouldRetry(response: Response): boolean { + const verdict = response.headers.get("x-should-retry"); + if (verdict === "true") return true; + if (verdict === "false") return false; + return response.status === 408 || response.status === 409 + || response.status === 429 || response.status >= 500; +} + +/** One logical request through a client whose retries are enabled. */ +async function sendWithClientRetries( + url: URL, + body: Record, + maxRetries = 2, +): Promise<{ response: Response; attempts: number; json: { error?: { code?: string } } }> { + let attempts = 0; + for (;;) { + attempts += 1; + const response = await originalFetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (attempts > maxRetries || !sdkWouldRetry(response)) { + return { response, attempts, json: await response.json() as { error?: { code?: string } } }; + } + // Release the body before the next attempt, as the SDKs do. + await response.text(); + } +} + +/** + * Count physical upstream sends and answer each one as the fixture dictates. Everything not + * addressed to the fixture host -- the client's own calls included -- keeps the real fetch. + */ +function countingUpstream(answer: () => Response): () => number { + let sends = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input + : input instanceof URL ? input.href + : input.url; + if (!url.includes(UPSTREAM_HOST)) return originalFetch(input as RequestInfo, init); + sends += 1; + return answer(); + }) as typeof fetch; + return () => sends; +} + +/** A half-closed pooled socket: the request has left, and nothing comes back. */ +function preHeaderReset(): never { + throw Object.assign( + new Error("The socket connection was closed unexpectedly."), + { code: "ECONNRESET" }, + ); +} + +function parityConfig(): OcxConfig { + const provider = (apiKey: string, adapter: string) => ({ + adapter, + baseUrl: `https://${UPSTREAM_HOST}/v1`, + authMode: "key", + apiKey, + models: ["model"], + }); + return { + port: 0, + defaultProvider: "native", + providers: { + // Native Chat keeps the caller on the Chat wire; the bridged row translates through + // Responses and back, which is the surface that used to lose the refusal. + native: provider("sk-native", "openai-chat"), + bridged: provider("sk-bridged", "openai-responses"), + }, + } as unknown as OcxConfig; +} + +const CHAT_TURN = { messages: [{ role: "user", content: "ping" }] }; +const RESPONSES_TURN = { input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }] }; + +test("every HTTP surface answers an ambiguous reset with one send and no client resend", async () => { + saveConfig(parityConfig()); + const sends = countingUpstream(preHeaderReset); + const server = startServer(0); + const surfaces = [ + { name: "native Chat", path: "/v1/chat/completions", body: { model: "native/model", ...CHAT_TURN } }, + { name: "translated Chat", path: "/v1/chat/completions", body: { model: "bridged/model", ...CHAT_TURN } }, + { name: "Responses", path: "/v1/responses", body: { model: "bridged/model", ...RESPONSES_TURN } }, + ]; + try { + for (const surface of surfaces) { + const before = sends(); + const { response, attempts, json } = await sendWithClientRetries( + new URL(surface.path, server.url), + surface.body, + ); + // The number this refusal exists to hold at one, per logical request. + expect({ surface: surface.name, sends: sends() - before, attempts }) + .toEqual({ surface: surface.name, sends: 1, attempts: 1 }); + expect({ surface: surface.name, status: response.status, code: json.error?.code }).toEqual({ + surface: surface.name, + status: REPLAY_REFUSED_STATUS, + code: UPSTREAM_RESET_REPLAY_REFUSED_CODE, + }); + // No wait to honour, and no automatic resend of a turn that may already have run. + expect(response.headers.get("Retry-After")).toBeNull(); + expect(response.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER)).toBe(REPLAY_REFUSAL_NO_RETRY_VALUE); + } + } finally { + await server.stop(true); + } +}); + +/** + * The control that keeps the assertion above honest. A client double that never resends would + * pin "one send" for any answer at all, so the same client has to be shown resending a real + * rate limit -- the answer a refusal was indistinguishable from on the translated surface. + */ +test("the same client still resends an ordinary upstream rate limit", async () => { + saveConfig(parityConfig()); + const sends = countingUpstream(() => new Response( + JSON.stringify({ error: { message: "Too many requests", type: "rate_limit_error" } }), + { status: 429, headers: { "content-type": "application/json", "retry-after": "0" } }, + )); + const server = startServer(0); + try { + const { response, attempts } = await sendWithClientRetries( + new URL("/v1/chat/completions", server.url), + { model: "native/model", ...CHAT_TURN }, + ); + expect(response.status).toBe(429); + expect(response.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER)).toBeNull(); + expect(attempts).toBe(3); + expect(sends()).toBeGreaterThan(1); + } finally { + await server.stop(true); + } +}); From 0ab648b4e350fd0602a2b42eec44f7993bc62bf4 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 08:51:18 +0900 Subject: [PATCH 56/87] fix(chat): keep a mid-conversation instruction in its slot on the translated Chat inbound (#5381) * fix(chat): keep a mid-conversation instruction in its slot on the translated inbound A Chat Completions request that carries an instruction after the conversation has started lost where it was written. src/chat/inbound.ts routed every system and developer message into systemParts and joined them into body.instructions, so U1 -> A1 -> D2 -> U2 reached the router as instructions plus a three-message input. The position was gone before any adapter ran, and the outbound adapter that has kept that slot since #4161 had nothing left to preserve. Past the first turn the message now becomes the input item the rest of the pipeline already reads: { type: "message", role: "developer", content: [input_text] }, the same representation src/claude/inbound.ts mints for the same shape. A leading block still folds into instructions, so the ordinary prompt head and its prefix-cache behaviour are unchanged. The role is developer rather than system on purpose. The native ChatGPT backend refuses a system item inside input, and canonical forwarding folds a message-shaped system item back onto instructions, which would undo the placement one hop later. An instruction that arrives between a tool call and its result waits for the batch to drain instead of splitting the pair: Kiro refuses an interrupted pair and the Anthropic and Google mappers synthesize a missing result. It rejoins the timeline as soon as the batch closes or the next user or assistant turn begins, so its order relative to the conversation is unchanged. * test(chat): prove instruction placement on the Chat, combo and Responses paths The translator cases cover the split this change introduces: a leading block still becomes body.instructions, a later one becomes a chronological item, and an instruction that arrives inside a tool batch waits for the batch rather than splitting a call from its result. The expected item for a mid-conversation system message is derived from src/claude/inbound.ts rather than restated, so the two inbounds cannot drift apart. The cross-path cases send one transcript three ways against one upstream and read the final wire body: the native Chat route, a combo route that forces translation, and the Responses endpoint carrying the same input. All three must equal the transcript the caller sent. Handing an already-translated object to the adapter would not have caught this, because the position was gone before the adapter ran. The file is registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. * docs(structure): record where a Chat instruction lands on the translated bridge The inbound compatibility doc described the native Chat passthrough and the wire-side placement contract, but said nothing about which of the two homes a system or developer message reaches on the translated bridge. State it: a leading block is the request's instructions, a later one is a chronological developer item, and an instruction inside a tool batch waits for the batch to drain. The wire slot and the role it carries stay owned by the chronological in-conversation instructions contract, which this doc already links. --- scripts/test-layout/layout.json | 3 +- src/chat/inbound.ts | 67 ++++- structure/data-planes/inbound-compat.md | 15 + tests/fixtures/test-layout-expected.json | 3 +- .../chat-inbound-developer-position.test.ts | 277 ++++++++++++++++++ 5 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 tests/responses/chat-inbound-developer-position.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d1f26dcba77..b3629cb16ec 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1630,7 +1630,8 @@ "responses-reset-replay.test.ts": "responses", "ambiguous-resend-gate.test.ts": "lib", "docs-developer-role-policy.test.ts": "ci-workflows", - "ambiguous-resend-composition.test.ts": "lib" + "ambiguous-resend-composition.test.ts": "lib", + "chat-inbound-developer-position.test.ts": "responses" }, "migrated": [ "adapters", diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index 1669207c447..8630cc0dd79 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -167,7 +167,27 @@ function pushSystemText(parts: string[], content: unknown): void { if (text) parts.push(text); } -function toolCallsToItems(toolCalls: unknown, input: Rec[], knownNameByCallId: Map): void { +/** + * A mid-conversation instruction, as the input item the rest of the pipeline already reads. + * + * The role is `developer` rather than `system` for two reasons that both bite. The native + * ChatGPT backend refuses a `role:"system"` item inside `input`, and canonical forwarding + * folds every message-shaped `system` item back onto `instructions` + * (src/adapters/openai-responses/canonical-forward.ts), which would undo the placement one hop + * later. `developer` is first-class in responsesRequestSchema, survives parseRequest as a + * chronological conversation message, and is exactly what src/claude/inbound.ts already emits + * for the same shape. + */ +function developerInstructionItem(text: string): Rec { + return { type: "message", role: "developer", content: [{ type: "input_text", text }] }; +} + +function toolCallsToItems( + toolCalls: unknown, + input: Rec[], + knownNameByCallId: Map, + awaitingToolResult: Set, +): void { if (!Array.isArray(toolCalls)) return; for (const raw of toolCalls) { if (!isRec(raw)) continue; @@ -187,6 +207,7 @@ function toolCallsToItems(toolCalls: unknown, input: Rec[], knownNameByCallId: M if (!name) throw new ChatCompletionsRequestError("tool_calls entries require function.name"); knownNameByCallId.set(callId, name); input.push({ type: "function_call", call_id: callId, name, arguments: args }); + awaitingToolResult.add(callId); } } @@ -368,21 +389,54 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { // Recover replace-style tool calls incrementally instead of rebuilding the // call-id index from the entire translated transcript for every message. const knownNameByCallId = new Map(); + // Tool calls whose result has not arrived yet. Several adapters need a call and its output + // to stay adjacent — Kiro refuses an interrupted pair (src/adapters/kiro/payload.ts) and the + // Anthropic and Google mappers synthesize a missing result — so an instruction that arrives + // inside an open batch waits for the batch to drain instead of splitting it. + const awaitingToolResult = new Set(); + const heldInstructions: string[] = []; + const releaseHeldInstructions = (): void => { + if (heldInstructions.length === 0) return; + input.push(developerInstructionItem(heldInstructions.join("\n\n"))); + heldInstructions.length = 0; + }; + // A user or assistant turn ends any open tool batch, so held text rejoins the timeline + // before that turn rather than drifting past it. + const beginConversationTurn = (): void => { + releaseHeldInstructions(); + awaitingToolResult.clear(); + }; for (const msg of raw.messages) { if (!isRec(msg)) continue; const role = typeof msg.role === "string" ? msg.role : ""; switch (role) { case "system": - case "developer": - pushSystemText(systemParts, msg.content); + case "developer": { + // A leading block is this request's instructions and keeps that treatment: it is the + // prompt head, and hoisting it is what the upstream prefix cache wants. + if (input.length === 0) { + pushSystemText(systemParts, msg.content); + break; + } + // Past the first turn the slot carries meaning. `U1 -> A1 -> D2 -> U2` says D2 applies + // to U2 and not to U1, and folding it into `instructions` moved it ahead of both while + // rewriting the prompt head on every turn that carried one. The outbound adapter has + // preserved this slot since #4161; the position was already gone by the time it ran. + const text = contentToText(msg.content).trim(); + if (!text) break; + if (awaitingToolResult.size > 0) heldInstructions.push(text); + else input.push(developerInstructionItem(text)); break; + } case "user": { + beginConversationTurn(); const blocks = userContentToBlocks(msg.content); if (blocks.length > 0) input.push({ type: "message", role: "user", content: blocks }); break; } case "assistant": { + beginConversationTurn(); // A reasoning item precedes the assistant message it belongs to: the // Responses assistant item schema admits only output content blocks, so there // is no attachment point on the message itself, and the parser buffers a @@ -405,7 +459,9 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { } const blocks = assistantContentToBlocks(msg.content); if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks }); - if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input, knownNameByCallId); + if (msg.tool_calls !== undefined) { + toolCallsToItems(msg.tool_calls, input, knownNameByCallId, awaitingToolResult); + } break; } case "function": { @@ -428,12 +484,15 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { ? blocks.filter(part => part.type === "input_text" || part.type === "input_image") : contentToText(msg.content); input.push({ type: "function_call_output", call_id: callId, output }); + awaitingToolResult.delete(callId); + if (awaitingToolResult.size === 0) releaseHeldInstructions(); break; } default: break; } } + releaseHeldInstructions(); if (input.length === 0 && systemParts.length === 0) { throw new ChatCompletionsRequestError("messages must include at least one user/assistant/tool turn"); diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index a8d1d1ba399..6b87977760f 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -110,6 +110,21 @@ or storage semantics retain the existing Chat -> Responses -> Chat bridge. Chat-to-Responses traffic that lands on `api.meta.ai` inherits the same 64-character tool-name aliasing as native Responses; see [`responses.md`](../transports/responses.md). +On that bridge, `src/chat/inbound.ts` decides where a `system` or `developer` message lands by +where the caller wrote it. A leading block, before any conversational item exists, becomes +`instructions`. One that arrives after the conversation has started becomes a chronological +`role:"developer"` input item instead, the same representation `src/claude/inbound.ts` mints for a +mid-conversation instruction, so the slot the caller chose survives to the adapter that preserves +it. The role is `developer` rather than `system` because the native ChatGPT backend refuses a +`system` item inside `input` and canonical forwarding folds a message-shaped `system` item back +onto `instructions`. An instruction that arrives between a tool call and its result is held until +the batch drains, or until the next user or assistant turn, so the pair the Kiro, Anthropic and +Google mappers require to stay adjacent is never split. Placement on the wire is then owned by +[chronological in-conversation instructions](../providers/chat-compat.md#chronological-in-conversation-instructions), +which also decides which role that slot carries. Regression coverage is in +`tests/responses/chat-inbound-developer-position.test.ts`, which compares the final upstream body +on the native Chat route, a combo route and the Responses endpoint. + The direct SSE relay accepts CRLF and arbitrary transport chunk boundaries while retaining at most one bounded event. EOF with an unterminated event and an event above the translator limit are typed upstream failures, never successful partial completions. Provider-controlled structured error diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1484512a4c4..8f764a5f531 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1462,5 +1462,6 @@ "responses-reset-replay.test.ts": "responses", "ambiguous-resend-gate.test.ts": "lib", "docs-developer-role-policy.test.ts": "ci-workflows", - "ambiguous-resend-composition.test.ts": "lib" + "ambiguous-resend-composition.test.ts": "lib", + "chat-inbound-developer-position.test.ts": "responses" } diff --git a/tests/responses/chat-inbound-developer-position.test.ts b/tests/responses/chat-inbound-developer-position.test.ts new file mode 100644 index 00000000000..9ef81f5ded8 --- /dev/null +++ b/tests/responses/chat-inbound-developer-position.test.ts @@ -0,0 +1,277 @@ +/** + * A Chat Completions instruction that arrives after the conversation has started keeps the + * slot it was written in, on every path that carries the same transcript. + * + * The translator folded every system and developer message into body.instructions, so + * U1 -> A1 -> D2 -> U2 reached the router as instructions plus a three-message input. The + * outbound adapter has preserved that slot since #4161 and cannot restore what the inbound + * already flattened, and the divergence only appears once a routing feature turns translation + * on: an ordinary openai-chat route goes straight to the Chat wire, while a combo enters the + * Responses pipeline (src/server/chat-completions.ts). + * + * The cross-path cases below therefore read the final upstream body on all three paths and + * compare it with the transcript the caller sent. A case that hands an already-translated + * object to the adapter cannot see this defect at all. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { responsesRequestSchema } from "../../src/responses/schema"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleResponses } from "../../src/server/responses"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; +import { chatStream, chatSuccess } from "../helpers/combo-failover-upstream"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +type Rec = Record; + +// One transcript for every case here: an instruction written between two turns. +const U1 = { role: "user", content: "U1" }; +const A1 = { role: "assistant", content: "A1" }; +const D2 = { role: "developer", content: "D2" }; +const U2 = { role: "user", content: "U2" }; +const TRANSCRIPT = [U1, A1, D2, U2]; + +function translate(messages: unknown[], extra: Rec = {}): Rec { + return chatCompletionsToResponsesBody({ model: "m1", messages, ...extra }); +} + +function items(body: Rec): Rec[] { + return body.input as Rec[]; +} + +function instructionItem(body: Rec): Rec | undefined { + return items(body).find(item => item.role === "developer"); +} + +function itemShape(body: Rec): string[] { + return items(body).map(item => String(item.type) + ":" + String(item.role ?? "")); +} + +describe("Chat translation inbound instruction placement", () => { + test("a leading block is still this request's instructions", () => { + const body = translate([{ role: "system", content: "S0" }, { role: "developer", content: "D0" }, U1]); + + expect(body.instructions).toBe("S0\n\nD0"); + expect(items(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "U1" }] }, + ]); + }); + + test("a developer message past the first turn keeps its slot instead of moving to instructions", () => { + const body = translate(TRANSCRIPT); + + expect(body.instructions).toBeUndefined(); + expect(items(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "U1" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "A1" }] }, + { type: "message", role: "developer", content: [{ type: "input_text", text: "D2" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "U2" }] }, + ]); + }); + + test("a leading block and a later one land in different places", () => { + const body = translate([{ role: "system", content: "S0" }, ...TRANSCRIPT]); + + expect(body.instructions).toBe("S0"); + expect(itemShape(body)).toEqual([ + "message:user", "message:assistant", "message:developer", "message:user", + ]); + }); + + // The representation is the Claude inbound's, not a second invention: that path has carried a + // mid-conversation instruction as a chronological item since #4148, and a system item would be + // refused by the native ChatGPT backend and folded back onto instructions by canonical + // forwarding (src/adapters/openai-responses/canonical-forward.ts). Deriving the expected item + // from that translator is what keeps the two inbounds in step. + test("a mid-conversation system message takes the item the Claude inbound already mints", () => { + const claude = anthropicToResponsesBody({ + model: "m1", + messages: [U1, A1, { role: "system", content: "D2" }, U2], + }); + const chat = translate([U1, A1, { role: "system", content: "D2" }, U2]); + + expect(instructionItem(claude)).toBeDefined(); + expect(instructionItem(chat)).toEqual(instructionItem(claude)); + expect(chat.instructions).toBeUndefined(); + }); + + test("the translated body is a valid Responses request and parses back as a conversation message", () => { + const body = translate(TRANSCRIPT); + + expect(responsesRequestSchema.safeParse(body).success).toBe(true); + const parsed = parseRequest(body); + expect(parsed.context.messages.map(message => message.role)).toEqual([ + "user", "assistant", "developer", "user", + ]); + expect(parsed.context.systemPrompt ?? []).toEqual([]); + expect(JSON.stringify(parsed.context.messages[2])).toContain("D2"); + }); + + test("an instruction inside a tool batch waits for the batch instead of splitting the pair", () => { + const body = translate([ + U1, + { role: "assistant", tool_calls: [{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }] }, + { role: "developer", content: "D-mid" }, + { role: "tool", tool_call_id: "call_1", content: "R1" }, + U2, + ]); + + expect(itemShape(body)).toEqual([ + "message:user", "function_call:", "function_call_output:", "message:developer", "message:user", + ]); + expect(items(body)[3]).toEqual({ + type: "message", role: "developer", content: [{ type: "input_text", text: "D-mid" }], + }); + expect(body.instructions).toBeUndefined(); + }); + + test("an abandoned tool batch still releases the held instruction before the next turn", () => { + const body = translate([ + U1, + { role: "assistant", tool_calls: [{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }] }, + { role: "developer", content: "D-mid" }, + U2, + ]); + + expect(itemShape(body)).toEqual([ + "message:user", "function_call:", "message:developer", "message:user", + ]); + }); + + test("an empty instruction produces no item and no instructions", () => { + const body = translate([U1, { role: "developer", content: " " }, U2]); + + expect(itemShape(body)).toEqual(["message:user", "message:user"]); + expect(body.instructions).toBeUndefined(); + }); +}); + +describe("Chat, combo and Responses paths agree on instruction placement", () => { + let testDir = ""; + let previousHome: string | undefined; + let isolatedCodexHome: IsolatedCodexHome | null = null; + let releaseSpendHome: (() => void) | undefined; + let upstream: ReturnType | null = null; + let captured: Rec[] = []; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-developer-position-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-developer-position-")); + process.env.OPENCODEX_HOME = testDir; + // Taken after this suite installs its home so the physical dispatch owns that journal. + releaseSpendHome = acquireOwnedSpendHome(); + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearResponseStateForTests(); + captured = []; + upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const body = await request.json() as Rec; + captured.push(body); + // Answer the wire each path asked for: the native send keeps the caller's stream bit, + // the translated bridge always streams internally. + return body.stream === true ? chatStream("ok") : chatSuccess("ok"); + }, + }); + }); + + afterEach(async () => { + // Released before the home is restored so no live unlinked database survives teardown. + releaseSpendHome?.(); + releaseSpendHome = undefined; + await upstream?.stop(true); + upstream = null; + await flushResponseState(); + clearResponseStateForTests(); + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + resetProviderRequestPacingForTest(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); + }); + + function config(): OcxConfig { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: new URL("/v1", upstream!.url).href, + allowPrivateNetwork: true, + authMode: "key", + apiKey: "fixture-key", + // Declared accepting, so the wire role is identical on the native and translated paths + // and the comparison below is about placement alone. + foldDeveloperRoleToSystem: false, + }; + return { + port: 0, + defaultProvider: "chat", + providers: { chat: provider }, + combos: { pair: { strategy: "failover", targets: [{ provider: "chat", model: "m1" }] } }, + }; + } + + async function chatWireMessages(model: string): Promise { + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model, stream: false, messages: TRANSCRIPT }), + }), config(), { model: "", provider: "" }); + + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + return captured[0]!.messages; + } + + async function responsesWireMessages(): Promise { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "chat/m1", + stream: false, + store: false, + // The canonical Responses spelling of the same transcript. + input: items(translate(TRANSCRIPT)), + }), + }), config(), { model: "", provider: "" }); + + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + return captured[0]!.messages; + } + + test("the native Chat route forwards the transcript unchanged", async () => { + expect(await chatWireMessages("chat/m1")).toEqual(TRANSCRIPT); + }); + + test("a combo route translates and still sends the transcript unchanged", async () => { + expect(await chatWireMessages("combo/pair")).toEqual(TRANSCRIPT); + }); + + test("the Responses path sends the same transcript", async () => { + expect(await responsesWireMessages()).toEqual(TRANSCRIPT); + }); +}); From 5d3f5db84afb04a79adc3a322c513c1677e181fe Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 08:59:38 +0900 Subject: [PATCH 57/87] feat(integrations): write the ZCode provider store the client actually reads (#5380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(integrations): refuse a client write that the client cannot read ZCode 3.14 moved custom providers to v2/provider_config.json and left v2/config.json reachable only through a one-shot import that runs when the new file is missing. The client creates the new file on first launch, so on an install that has ever run the import is already spent and every later write to v2/config.json is read by nobody. Apply, overwrite and refresh reported success, the journal recorded a correct operation, and no model ever appeared. Name the store the client reads and refuse the write instead of reporting it. The predicate is a declared registry resolver rather than a zcode branch in the writer, it is observed through the same IO seam the config file is observed through, and it is bound into the plan fingerprint so a confirmation taken before the client migrated cannot commit afterwards. Disable is untouched: removing bytes this project wrote to this file is as effective as it ever was. Refs #5348 * feat(integrations): surface the superseded store in the dashboard and the CLI The refusal reason has to be recognised by the GUI parser or it reaches the user as an unrecognised error, and passive status has to be able to say it too: the badge is telling the truth about the file when it reports current, and the client has still stopped reading that file. The status notice follows the Raycast plan notice, which already answers the same question for a written file the client ignores. The refusal keeps the server message, which names both paths, rather than a localized replacement that could not. Refs #5348 * test(integrations): cover the refusal, the surviving disable, and the store predicate The cases derive both locations from the registry resolvers rather than restating them, so a move of either file carries the fixture with it. They pin the parts that are easy to get wrong in the other direction too: an absent store leaves the existing write path untouched, a directory at the store path is not evidence that the client migrated, and disable still removes a block applied before the move. The plan-fingerprint case asserts the version through the one exported value that carries it instead of a literal that a bump would leave stale. Refs #5348 * docs: state where the ZCode integration stops being effective, and why it refuses The guide gains the version boundary, what the switch does at it, and the supported way to reach the proxy from a client that moved its store. The structure document records the rule as a general one, because the shape recurs: an old path kept alive by a one-shot import looks indistinguishable from a working target until a model fails to appear. It also records what naming the new store does not authorize. Writing a schema this project has not observed, into a file holding the user other providers, trades a silent no-op for a silent loss. Refs #5348 * fix(gui): keep the plan token opaque at the dashboard boundary The dashboard parser re-declares the plan vocabulary by hand because it cannot import the backend, and it pinned the fingerprint version as a literal. Binding one more input into that token would therefore have turned every preview response into a client-side rejection, silently, with no test in a position to see it. Match the version as a version and read the unbound marker by its suffix, then cross a real plan and a real refusal through the parser in the test that already exists to catch hand-maintained drift between these two lists. * refactor(integrations): address a sequence element by everything that identifies it A managed path could name one field of an array element. That is enough for Raycast, whose providers are keyed by id, and it is not enough for a store that keys an entry by a pair: a selector naming only the model would match another provider's rule for the same model and replace it, in a file holding every provider the user has. Accept a conjunction of field=value criteria and require all of them. The single-criterion spelling keeps its old grammar, including a value containing a comma, so no path already written into an ownership record changes meaning. The path reader moves to the module that owns the path grammar and is re-exported from its old home. One implementation is the point: a reader that resolved a selector differently from the writer would report one element as ours and rewrite another. * feat(clients): build the provider store ZCode 3.14 reads The exporter has always built the block for v2/config.json. This adds the second shape: one provider rule and one model rule per row inside v2/provider_config.json, addressed by the pair the store keys a model rule on. The shape is the client's own. It is what its importLegacy migration produced when it converted this project's block, quoted in #5348, so the nesting is observed output rather than a schema invented here. Anything the client persists that we have not seen derived from our block is absent, because an omitted field is a field the client still owns: no reasoning ladder, no output budget, and no model rule at all for a row without an authoritative context window. A schema predicate travels with the builder. Only the one version whose shape has been observed is writable; any other store is a file the integration reports as unwritable rather than merges into. Refs #5348 * feat(integrations): write the store the client reads, and refuse only when it cannot The refusal this branch shipped is now the fallback rather than the answer. Where the client's provider store carries a schema whose shape has been observed, apply, refresh and disable all act on that file: the write reaches the client, a refresh updates it, and disable removes exactly the fragments the record names from the file the record names. The choice is one function, taken before anything is classified, from three facts in order. No store on disk means the config file, unchanged, because a client that has never run still imports what we write there. Our own block already in a file keeps the operation on that file, whichever of the two it is, so disable stays symmetric with the enable that produced it and no apply leaves a block in one file while writing another. Otherwise the store, and only when its schema establishes. Everything downstream follows the target rather than the client: the document format it is parsed and serialized in, the contribution shape its reader understands, the ownership record, the journal row, and the path an undo is allowed to rewrite. Undo previously required the row to name the config file, which would have made a store operation unrestorable. The plan binds a reason together with the location, because a store whose schema stops being one we recognise changes the answer while its path stays the same. Refs #5348 * test(integrations): cover the store write, the ownership it must not take, and the fallback The new cases assert the symmetry the feature is for: after enable the store the client reads carries the intended provider, after a refresh it carries the new roster, and after disable it carries nothing this project put there while the file itself and every rule the user owns are still standing. Two of them exist because the addressing could go wrong quietly. A model rule belonging to another provider, with the same model id as ours, survives an apply and a disable — a selector naming only the model would have replaced it. And a provider rule carrying our id that no record of ours accounts for is refused rather than taken over, with overwrite as the only way past it. The fallback is covered as a fallback: a store one version past the shape that has been observed is left byte for byte alone and reported, and a block applied before the client moved keeps the operation on the file it is in rather than orphaning it. Locations come from the exported schema constants and the contribution is compared against the builder through the same export context the writer derives, so nothing here restates a source constant. Refs #5348 * docs: state that the ZCode integration writes the store the client reads The guide previously described a refusal as the whole answer. It now describes the write: what enable puts in the store, what a refresh updates, what disable removes, and what it leaves standing, including a rule another provider keeps for a model id that also appears under ours. Both refusals survive as the cases they are — a block applied before the client moved, and a store whose schema version is not one this project has observed — with the two-step remedy for the first and the manual provider entry for the second. The structure document records the target resolution as a general rule, because the shape recurs. A client that moves its store needs more than a location declared: the format, the contribution shape its reader understands, and a predicate for the version. What it still does not authorize is unchanged and now stated where the writing rule is, so the two cannot drift apart. Refs #5348 * fix(gui): let the dashboard accept the paths a store plan publishes The parser keeps its own set of managed schema paths, by hand, because it cannot import the backend. A plan for the provider store publishes two templates that set had never heard of, so every preview for a client writing its store would have been rejected client-side as an invalid response and the page would have shown nothing. The second half is the part that keeps it from happening again. Rather than crossing one plan through the parser, the parity case now crosses every template the backend declares for every client, so a client that starts writing a second file is visible here the day it is added. * test(integrations): assert the field the target actually carries and bind the parser fixture --- .../content/docs/fr/guides/integrations.md | 30 ++ .../src/content/docs/guides/integrations.md | 26 ++ .../content/docs/tr/guides/integrations.md | 28 ++ .../content/docs/zh-tw/guides/integrations.md | 21 ++ gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + .../integrations/FileIntegrationPage.tsx | 8 + .../integrations/IntegrationPlanDetails.tsx | 1 + gui/src/pages/integrations/integration-api.ts | 24 +- scripts/test-layout/layout.json | 2 + src/cli/integrations.ts | 8 +- src/cli/registry.ts | 1 + src/clients/config-export.ts | 36 +++ src/clients/config-export/zcode-store.ts | 154 ++++++++++ src/integrations/merge.ts | 96 +++++- src/integrations/mutation-plan.ts | 126 ++++++-- src/integrations/registry.ts | 38 +++ src/integrations/state.ts | 107 ++++--- src/integrations/target.ts | 205 +++++++++++++ src/integrations/writer.ts | 60 +++- structure/clients/integrations.md | 57 +++- .../integrations-current-store.test.ts | 286 ++++++++++++++++++ tests/clients/integrations-merge.test.ts | 23 +- .../integrations-superseded-store.test.ts | 211 +++++++++++++ tests/clients/mutation-plan.test.ts | 20 +- tests/fixtures/test-layout-expected.json | 2 + tests/gui/integrations-invariants.test.ts | 92 ++++++ 35 files changed, 1592 insertions(+), 90 deletions(-) create mode 100644 src/clients/config-export/zcode-store.ts create mode 100644 src/integrations/target.ts create mode 100644 tests/clients/integrations-current-store.test.ts create mode 100644 tests/clients/integrations-superseded-store.test.ts diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 38fb208516d..1f3e04ada55 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -254,6 +254,36 @@ Les détails des clients ont été vérifiés par rapport au format de configura consultez les notes de recherche dans `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` pour savoir ce qui a été contrôlé et quand. +## ZCode 3.14 et versions ultérieures + +ZCode 3.14 a déplacé ses fournisseurs personnalisés vers `~/.zcode/v2/provider_config.json` et ne +lit plus `~/.zcode/v2/config.json` qu'au travers d'un import unique, exécuté seulement quand le +nouveau fichier est absent. ZCode crée ce nouveau fichier au premier lancement : sur toute +installation déjà démarrée une fois, l'import a donc déjà eu lieu et une écriture dans +`config.json` n'atteint plus rien. + +opencodex écrit désormais `provider_config.json` directement quand il le peut. Activer +l'intégration ajoute la règle de fournisseur `opencodex` dans ce fichier, une actualisation du +catalogue la met à jour, et la désactivation retire exactement ce qu'opencodex y a mis. Toutes les +autres règles du fichier restent intactes, y compris celle qu'un autre fournisseur conserve pour un +identifiant de modèle qui figure aussi chez nous. Une règle portant l'identifiant `opencodex` +qu'opencodex n'a pas écrite est un conflit et non quelque chose à reprendre : réglez-la dans ZCode, +ou utilisez l'écrasement explicite. + +Deux situations refusent encore au lieu d'écrire. Un bloc écrit par opencodex avant le déplacement +du stockage maintient l'intégration sur `config.json` : désactivez-la d'abord à cet endroit, puis +réactivez-la pour écrire le nouveau stockage. Et un `provider_config.json` dont le +`schemaVersion` n'est pas un de ceux qu'opencodex a observés est signalé plutôt que fusionné : +ce fichier contient tous les fournisseurs de ZCode, et y affirmer une forme échangerait une +absence d'effet silencieuse contre une perte silencieuse. L'état nomme le fichier que ZCode lit dès +que l'intégration ne l'écrit pas. + +Dans ce second cas, ajoutez le fournisseur dans les réglages de ZCode : URL de base +`http://127.0.0.1:10100/v1` (ajustez le port à votre écoute), une clé non vide quelconque, et les +identifiants de modèle donnés par `ocx export --client zcode`. Supprimer +`provider_config.json` pour relancer l'import de ZCode n'est pas pris en charge : cela détruit +tous les fournisseurs que ZCode y conserve. + ## Cline CLI Cline CLI utilise providers.json et models.json. Quittez Cline avant toute modification ou synchronisation, puis redémarrez-le. Annuler restaure les deux originaux. Le fournisseur par défaut reste inchangé. Cette intégration ne migre pas le stockage des anciennes extensions VS Code. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 32263f12a67..2b32b59122e 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -375,6 +375,32 @@ catalogs are refused; the existing explicit overwrite and drift-confirmation con available. Fully quit and reopen Aside to load changed model files. +## ZCode 3.14 and later + +ZCode 3.14 moved its custom providers to `~/.zcode/v2/provider_config.json` and left +`~/.zcode/v2/config.json` reachable only through a one-shot import that runs when the new file is +missing. ZCode creates the new file the first time it runs, so on any install that has ever been +launched the import is already spent and a write to `config.json` reaches nothing. + +opencodex writes `provider_config.json` directly where it can. Enabling the integration adds the +`opencodex` provider rule to that file, a catalog refresh updates it, and disabling removes exactly +what opencodex put there. Every other rule in the file is left alone, including a rule another +provider keeps for a model id that also appears under ours. A rule carrying the `opencodex` id that +opencodex did not write is a conflict rather than something to take over; resolve it in ZCode, or +use the explicit overwrite. + +Two situations still refuse rather than write. A block opencodex applied before ZCode moved its +store keeps the integration on `config.json`: disable it there first, then enable it again to write +the new store. And a `provider_config.json` whose `schemaVersion` is not one opencodex has observed +is reported rather than merged into, because that file holds every provider ZCode has and asserting +a shape into it would trade a silent no-op for a silent loss. Status names the file ZCode reads +whenever the integration is not writing it. + +In that second case, add the provider in ZCode's own settings: base URL +`http://127.0.0.1:10100/v1` (adjust the port to your bind), any non-empty key, and the model ids +from `ocx export --client zcode`. Deleting `provider_config.json` to re-trigger ZCode's import is +not supported — it discards every provider ZCode keeps there. + ## Cline CLI This integration targets Cline's current CLI/shared SDK provider store, whose native schema has diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index c1cc94e2a33..298930cd408 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -281,6 +281,34 @@ doğrulanmıştır; neyin ne zaman denetlendiğine ilişkin `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` içindeki araştırma notlarına bakın. +## ZCode 3.14 ve sonrası + +ZCode 3.14 özel sağlayıcılarını `~/.zcode/v2/provider_config.json` dosyasına taşıdı; bu +entegrasyonun yazdığı `~/.zcode/v2/config.json` dosyasına artık yalnızca, yeni dosya yokken bir kez +çalışan bir içe aktarma üzerinden ulaşıyor. ZCode yeni dosyayı ilk çalıştırmada oluşturduğu için, +bir kez bile başlatılmış her kurulumda bu içe aktarma çoktan tükenmiştir ve `config.json` dosyasına +yazmak hiçbir şeye ulaşmaz. + +opencodex artık mümkün olduğunda `provider_config.json` dosyasını doğrudan yazıyor. Entegrasyonu +etkinleştirmek bu dosyaya `opencodex` sağlayıcı kuralını ekler, katalog yenilemesi onu günceller ve +devre dışı bırakmak opencodex'in oraya koyduğu şeyi tam olarak kaldırır. Dosyadaki diğer her kural +olduğu gibi kalır; buna başka bir sağlayıcının, bizde de bulunan bir model kimliği için tuttuğu +kural da dahildir. opencodex'in yazmadığı, `opencodex` kimliğini taşıyan bir kural devralınacak bir +şey değil, bir çakışmadır: ZCode içinde çözün ya da açık üzerine yazmayı kullanın. + +İki durum hâlâ yazmak yerine reddeder. ZCode deposunu taşımadan önce opencodex'in yazdığı bir blok, +entegrasyonu `config.json` üzerinde tutar: önce orada devre dışı bırakın, sonra yeni depoyu yazmak +için yeniden etkinleştirin. `schemaVersion` değeri opencodex'in gözlemlediklerinden biri olmayan +bir `provider_config.json` ise birleştirilmez, bildirilir: o dosya ZCode'un tüm sağlayıcılarını +tutar ve oraya bir şekil dayatmak sessiz bir etkisizliği sessiz bir kayıpla değiştirirdi. Durum +ekranı, entegrasyon o dosyayı yazmadığı her durumda ZCode'un okuduğu dosyayı adlandırır. + +Bu ikinci durumda sağlayıcıyı ZCode'un kendi ayarlarından ekleyin: temel URL +`http://127.0.0.1:10100/v1` (bağlantı noktasını kendi bağınıza göre ayarlayın), boş olmayan +herhangi bir anahtar ve `ocx export --client zcode` çıktısındaki model kimlikleri. ZCode'un içe +aktarmasını yeniden tetiklemek için `provider_config.json` dosyasını silmek desteklenmez: bu, +ZCode'un orada sakladığı tüm sağlayıcıları yok eder. + ## Cline CLI Cline CLI providers.json ve models.json kullanır. Değişiklik veya eşitleme öncesinde Cline’ı kapatın, sonra yeniden başlatın. Geri al iki özgün dosyayı geri yükler. Varsayılan sağlayıcı değişmez. Eski VS Code uzantısının depolaması taşınmaz. diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index c0f9ef2bf82..83f5cc7cf6a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -165,6 +165,27 @@ OAuth 或 API key,並拒絕 `--api-key`、`--base-url` 與 `--region` 覆寫 客戶端細節是針對各專案自己的設定格式驗證過的;檢查了什麼、何時檢查,請見 `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` 中的研究筆記。 +## ZCode 3.14 以後 + +ZCode 3.14 把自訂供應商移到 `~/.zcode/v2/provider_config.json`,而本整合原本寫入的 +`~/.zcode/v2/config.json` 只剩下一次性匯入會讀取,而那次匯入只在新檔案不存在時執行。ZCode 首次啟動 +就會建立新檔案,因此只要曾經啟動過的安裝,匯入早已用掉,之後寫入 `config.json` 不會被任何東西讀到。 + +在可行的情況下,opencodex 現在直接寫入 `provider_config.json`。啟用整合會把 `opencodex` 供應商規則 +加進該檔案,目錄重新整理會更新它,停用則精確移除 opencodex 放進去的內容。檔案中其他規則一律保持原樣, +包含其他供應商為某個同樣出現在我們這裡的模型 ID 所保留的規則。帶有 `opencodex` ID 但不是 opencodex +寫入的規則屬於衝突,而不是可以接管的東西:請在 ZCode 中處理,或使用明確的覆寫。 + +仍有兩種情況會拒絕而不寫入。ZCode 搬移儲存位置之前由 opencodex 寫入的區塊,會讓整合留在 +`config.json`:請先在那裡停用,再重新啟用以寫入新的儲存檔。至於 `schemaVersion` 不是 opencodex +曾觀察過的 `provider_config.json`,則只會被回報而不會合併:該檔案存放 ZCode 的所有供應商,對它斷言 +一種結構等於把靜默的無效果換成靜默的資料遺失。只要整合不是在寫那個檔案,狀態頁就會指出 ZCode 實際 +讀取的檔案。 + +在第二種情況下,請在 ZCode 自己的設定中新增供應商:base URL 為 `http://127.0.0.1:10100/v1` +(請依實際繫結調整連接埠)、任意非空白金鑰,以及 `ocx export --client zcode` 列出的模型 ID。不支援 +刪除 `provider_config.json` 來重新觸發 ZCode 的匯入:那會丟掉 ZCode 存放在其中的所有供應商。 + ## Cline CLI Cline CLI 使用 providers.json 與 models.json。修改或同步前請結束 Cline,完成後重新啟動。復原會還原兩個原始檔案,預設供應商保持不變。此整合不會遷移舊版 VS Code 擴充功能的儲存資料。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2ae1b8c57f4..6572435e62b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1433,6 +1433,7 @@ export const de: Record = { "integrations.status.installed": "Installiert", "integrations.status.notInstalled": "Nicht installiert", "integrations.status.appliedAt": "Angewendet", + "integrations.status.supersededStore": "Dieser Client liest seine Provider jetzt aus {path}, und opencodex schreibt diese Datei nicht. Ein Aktivieren hier ändert nichts an dem, was der Client lädt.", "integrations.status.backup": "Sicherung", "integrations.status.lastRestore": "Letzte Wiederherstellung", "integrations.status.unknown": "Unbekannt", @@ -1493,6 +1494,7 @@ export const de: Record = { "integrations.plan.refusal.conflict": "Die verwalteten Einstellungen stehen im Konflikt mit der aktuellen Datei. Prüfe die Datei oder verwende Ersetzen, wenn diese Aktion verfügbar ist.", "integrations.plan.refusal.unsafe": "Die Client-Konfiguration kann nicht sicher geändert werden. Repariere oder ersetze die Datei und versuche es erneut.", "integrations.plan.refusal.nonLoopback": "Dieser Client benötigt eine Loopback-Proxyadresse. Verwende localhost, einen Tunnel oder eine lokale Weiterleitung.", + "integrations.plan.refusal.supersededStore": "Dieser Client liest seine Provider aus einer Datei, die opencodex nicht schreibt. Dieser Vorgang würde nichts an dem ändern, was der Client lädt.", "integrations.plan.refusal.driftRequiresConfirm": "Die Datei wurde nach diesem Rollback-Punkt geändert. Prüfe vor der Wiederherstellung den Drift-Plan.", "integrations.plan.refusal.snapshotExpired": "Die Sicherung für diesen Rollback-Punkt ist abgelaufen und kann nicht mehr wiederhergestellt werden.", "integrations.plan.refusal.writeFailed": "Der Server konnte keinen sicheren Schreibvorgang vorbereiten. Aktualisiere den Integrationsstatus und versuche es erneut.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a2369042f96..2841e398e0a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2021,6 +2021,7 @@ export const en = { "integrations.status.installed": "Installed", "integrations.status.notInstalled": "Not installed", "integrations.status.appliedAt": "Applied", + "integrations.status.supersededStore": "This client now reads its providers from {path}, which opencodex does not write, so enabling it here would change nothing it loads.", "integrations.status.backup": "Backup", "integrations.status.lastRestore": "Last restore", "integrations.status.unknown": "Unknown", @@ -2081,6 +2082,7 @@ export const en = { "integrations.plan.refusal.conflict": "The managed settings conflict with the current file. Review the file or use Replace when that action is available.", "integrations.plan.refusal.unsafe": "The client configuration cannot be changed safely. Repair or replace the file before trying again.", "integrations.plan.refusal.nonLoopback": "This client requires a loopback proxy address. Use localhost access, a tunnel, or a local forwarder before trying again.", + "integrations.plan.refusal.supersededStore": "This client now reads its providers from a file opencodex does not write, so this operation would change nothing it loads.", "integrations.plan.refusal.driftRequiresConfirm": "The file changed after this rollback point. Review the drift-aware plan before restoring.", "integrations.plan.refusal.snapshotExpired": "The backup for this rollback point has expired, so it can no longer be restored.", "integrations.plan.refusal.writeFailed": "The server could not prepare a safe write. Refresh the integration state before trying again.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 977af5f5e6a..620dd888756 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1953,6 +1953,7 @@ export const fr: Record = { "integrations.status.installed": "Installé", "integrations.status.notInstalled": "Non installé", "integrations.status.appliedAt": "Appliqué", + "integrations.status.supersededStore": "Ce client lit désormais ses fournisseurs depuis {path}, un fichier qu'opencodex n'écrit pas : l'activer ici ne changerait rien à ce qu'il charge.", "integrations.status.backup": "Sauvegarde", "integrations.status.lastRestore": "Dernière restauration", "integrations.status.unknown": "Inconnu", @@ -2013,6 +2014,7 @@ export const fr: Record = { "integrations.plan.refusal.conflict": "Les paramètres gérés sont en conflit avec le fichier actuel. Vérifiez le fichier ou utilisez Remplacer si cette action est disponible.", "integrations.plan.refusal.unsafe": "La configuration du client ne peut pas être modifiée en toute sécurité. Réparez ou remplacez le fichier avant de réessayer.", "integrations.plan.refusal.nonLoopback": "Ce client exige une adresse proxy en boucle locale. Utilisez localhost, un tunnel ou un transfert local avant de réessayer.", + "integrations.plan.refusal.supersededStore": "Ce client lit ses fournisseurs depuis un fichier qu'opencodex n'écrit pas : cette opération ne changerait rien à ce qu'il charge.", "integrations.plan.refusal.driftRequiresConfirm": "Le fichier a changé après ce point de restauration. Vérifiez le plan tenant compte des changements avant de restaurer.", "integrations.plan.refusal.snapshotExpired": "La sauvegarde de ce point de restauration a expiré et ne peut plus être restaurée.", "integrations.plan.refusal.writeFailed": "Le serveur n’a pas pu préparer une écriture sûre. Actualisez l’état de l’intégration avant de réessayer.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 23bd87d4876..15629119eab 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1877,6 +1877,7 @@ export const ja: Record = { "integrations.status.installed": "インストール済み", "integrations.status.notInstalled": "未インストール", "integrations.status.appliedAt": "適用", + "integrations.status.supersededStore": "このクライアントは現在 {path} からプロバイダーを読み込みます。opencodex はこのファイルを書き込まないため、ここで有効にしても読み込む内容は変わりません。", "integrations.status.backup": "バックアップ", "integrations.status.lastRestore": "最終復元", "integrations.status.unknown": "不明", @@ -1937,6 +1938,7 @@ export const ja: Record = { "integrations.plan.refusal.conflict": "管理対象の設定が現在のファイルと競合しています。ファイルを確認するか、利用可能な場合は置き換えを使用してください。", "integrations.plan.refusal.unsafe": "クライアント設定を安全に変更できません。ファイルを修復または置き換えてから再試行してください。", "integrations.plan.refusal.nonLoopback": "このクライアントにはループバックのプロキシアドレスが必要です。localhost、トンネル、またはローカル転送を使用してください。", + "integrations.plan.refusal.supersededStore": "このクライアントは opencodex が書き込まないファイルからプロバイダーを読み込むため、この操作では読み込む内容は変わりません。", "integrations.plan.refusal.driftRequiresConfirm": "このロールバック地点以降にファイルが変更されました。変更を考慮したプランを確認してから復元してください。", "integrations.plan.refusal.snapshotExpired": "このロールバック地点のバックアップは期限切れのため、復元できません。", "integrations.plan.refusal.writeFailed": "サーバーは安全な書き込みを準備できませんでした。統合状態を更新してから再試行してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e6dbe127125..fdd2673669b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1469,6 +1469,7 @@ export const ko: Record = { "integrations.status.installed": "설치 감지됨", "integrations.status.notInstalled": "설치되지 않음", "integrations.status.appliedAt": "적용", + "integrations.status.supersededStore": "이 클라이언트는 이제 {path}에서 프로바이더를 읽습니다. opencodex는 이 파일을 쓰지 않으므로 여기서 켜도 클라이언트가 불러오는 내용은 달라지지 않습니다.", "integrations.status.backup": "백업", "integrations.status.lastRestore": "마지막 복원", "integrations.status.unknown": "알 수 없음", @@ -1529,6 +1530,7 @@ export const ko: Record = { "integrations.plan.refusal.conflict": "관리 설정이 현재 파일과 충돌합니다. 파일을 검토하거나 사용할 수 있다면 교체 작업을 선택하세요.", "integrations.plan.refusal.unsafe": "클라이언트 구성을 안전하게 변경할 수 없습니다. 파일을 복구하거나 교체한 뒤 다시 시도하세요.", "integrations.plan.refusal.nonLoopback": "이 클라이언트에는 루프백 프록시 주소가 필요합니다. localhost, 터널 또는 로컬 포워더를 사용한 뒤 다시 시도하세요.", + "integrations.plan.refusal.supersededStore": "이 클라이언트는 opencodex가 쓰지 않는 파일에서 프로바이더를 읽으므로, 이 작업으로는 클라이언트가 불러오는 내용이 달라지지 않습니다.", "integrations.plan.refusal.driftRequiresConfirm": "이 롤백 시점 이후 파일이 변경되었습니다. 변경 상태를 반영한 계획을 검토한 뒤 복원하세요.", "integrations.plan.refusal.snapshotExpired": "이 롤백 시점의 백업이 만료되어 더 이상 복원할 수 없습니다.", "integrations.plan.refusal.writeFailed": "서버가 안전한 쓰기 작업을 준비하지 못했습니다. 통합 상태를 새로 고친 뒤 다시 시도하세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 11a6c3d0011..98184790450 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1948,6 +1948,7 @@ export const ru: Record = { "integrations.status.installed": "Установлен", "integrations.status.notInstalled": "Не установлен", "integrations.status.appliedAt": "Применено", + "integrations.status.supersededStore": "Этот клиент теперь читает провайдеров из {path}, а opencodex этот файл не пишет, поэтому включение здесь ничего не изменит в том, что клиент загружает.", "integrations.status.backup": "Резервная копия", "integrations.status.lastRestore": "Последнее восстановление", "integrations.status.unknown": "Неизвестно", @@ -2008,6 +2009,7 @@ export const ru: Record = { "integrations.plan.refusal.conflict": "Управляемые настройки конфликтуют с текущим файлом. Проверьте файл или используйте замену, если она доступна.", "integrations.plan.refusal.unsafe": "Конфигурацию клиента нельзя безопасно изменить. Исправьте или замените файл перед повторной попыткой.", "integrations.plan.refusal.nonLoopback": "Этому клиенту нужен адрес прокси в loopback. Используйте localhost, туннель или локальную переадресацию.", + "integrations.plan.refusal.supersededStore": "Этот клиент читает провайдеров из файла, который opencodex не пишет, поэтому операция ничего не изменит в том, что он загружает.", "integrations.plan.refusal.driftRequiresConfirm": "Файл изменился после этой точки отката. Проверьте план с учётом изменений перед восстановлением.", "integrations.plan.refusal.snapshotExpired": "Резервная копия для этой точки отката истекла и больше не может быть восстановлена.", "integrations.plan.refusal.writeFailed": "Сервер не смог подготовить безопасную запись. Обновите состояние интеграции перед повторной попыткой.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5d425389fa1..c9fde3b6336 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1967,6 +1967,7 @@ export const tr: Record = { "integrations.status.installed": "Yüklü", "integrations.status.notInstalled": "Yüklü değil", "integrations.status.appliedAt": "Uygulandı", + "integrations.status.supersededStore": "Bu istemci sağlayıcılarını artık {path} dosyasından okuyor; opencodex bu dosyayı yazmadığı için buradan etkinleştirmek istemcinin yüklediklerini değiştirmez.", "integrations.status.backup": "Yedek", "integrations.status.lastRestore": "Son geri yükleme", "integrations.status.unknown": "Bilinmiyor", @@ -2027,6 +2028,7 @@ export const tr: Record = { "integrations.plan.refusal.conflict": "Yönetilen ayarlar mevcut dosyayla çakışıyor. Dosyayı inceleyin veya varsa Değiştir işlemini kullanın.", "integrations.plan.refusal.unsafe": "İstemci yapılandırması güvenle değiştirilemiyor. Yeniden denemeden önce dosyayı onarın veya değiştirin.", "integrations.plan.refusal.nonLoopback": "Bu istemci bir geri döngü proxy adresi gerektiriyor. localhost, tünel veya yerel yönlendirme kullanın.", + "integrations.plan.refusal.supersededStore": "Bu istemci sağlayıcılarını opencodex'in yazmadığı bir dosyadan okuyor; bu işlem istemcinin yüklediklerini değiştirmez.", "integrations.plan.refusal.driftRequiresConfirm": "Dosya bu geri alma noktasından sonra değişti. Geri yüklemeden önce değişikliği dikkate alan planı inceleyin.", "integrations.plan.refusal.snapshotExpired": "Bu geri alma noktasının yedeği sona erdiği için artık geri yüklenemez.", "integrations.plan.refusal.writeFailed": "Sunucu güvenli bir yazma işlemi hazırlayamadı. Yeniden denemeden önce entegrasyon durumunu yenileyin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 88c8fc92fa4..b8a6be355ea 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1970,6 +1970,7 @@ export const vi: Record = { "integrations.status.installed": "Đã cài đặt", "integrations.status.notInstalled": "Chưa cài đặt", "integrations.status.appliedAt": "Đã áp dụng", + "integrations.status.supersededStore": "Máy khách này hiện đọc danh sách nhà cung cấp từ {path}, tệp mà opencodex không ghi, nên bật ở đây sẽ không thay đổi những gì nó tải.", "integrations.status.backup": "Sao lưu", "integrations.status.lastRestore": "Lần khôi phục cuối", "integrations.status.unknown": "Không xác định", @@ -2030,6 +2031,7 @@ export const vi: Record = { "integrations.plan.refusal.conflict": "Cài đặt được quản lý xung đột với tệp hiện tại. Hãy xem lại tệp hoặc dùng Thay thế khi có thể.", "integrations.plan.refusal.unsafe": "Không thể thay đổi cấu hình máy khách một cách an toàn. Hãy sửa hoặc thay tệp trước khi thử lại.", "integrations.plan.refusal.nonLoopback": "Máy khách này cần địa chỉ proxy loopback. Hãy dùng localhost, đường hầm hoặc chuyển tiếp cục bộ.", + "integrations.plan.refusal.supersededStore": "Máy khách này đọc danh sách nhà cung cấp từ một tệp mà opencodex không ghi, nên thao tác này sẽ không thay đổi những gì nó tải.", "integrations.plan.refusal.driftRequiresConfirm": "Tệp đã thay đổi sau điểm khôi phục này. Hãy xem kế hoạch có tính đến thay đổi trước khi khôi phục.", "integrations.plan.refusal.snapshotExpired": "Bản sao lưu cho điểm khôi phục này đã hết hạn nên không thể khôi phục nữa.", "integrations.plan.refusal.writeFailed": "Máy chủ không thể chuẩn bị thao tác ghi an toàn. Hãy làm mới trạng thái tích hợp trước khi thử lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 109f4378918..58b41c90fdd 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2608,6 +2608,7 @@ export const zhTW: Record = { "integrations.status.installed": "已安裝", "integrations.status.notInstalled": "未安裝", "integrations.status.appliedAt": "已套用", + "integrations.status.supersededStore": "此用戶端現在從 {path} 讀取供應商,而 opencodex 不會寫入該檔案,因此在這裡啟用不會改變它載入的內容。", "integrations.status.backup": "備份", "integrations.status.lastRestore": "上次還原", "integrations.status.unknown": "未知", @@ -2668,6 +2669,7 @@ export const zhTW: Record = { "integrations.plan.refusal.conflict": "受管理的設定與目前檔案衝突。請檢查檔案,或在可用時使用「替換」。", "integrations.plan.refusal.unsafe": "無法安全地變更用戶端設定。請修復或替換檔案後再試一次。", "integrations.plan.refusal.nonLoopback": "此用戶端需要迴路代理位址。請使用 localhost、通道或本機轉送後再試一次。", + "integrations.plan.refusal.supersededStore": "此用戶端從 opencodex 不會寫入的檔案讀取供應商,因此這項操作不會改變它載入的內容。", "integrations.plan.refusal.driftRequiresConfirm": "檔案在此回復點後已變更。請先檢視包含變更的計畫再還原。", "integrations.plan.refusal.snapshotExpired": "此回復點的備份已過期,無法再還原。", "integrations.plan.refusal.writeFailed": "伺服器無法準備安全寫入。請重新整理整合狀態後再試一次。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b550c7eb086..5eb414a0ad7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1450,6 +1450,7 @@ export const zh: Record = { "integrations.status.installed": "已安装", "integrations.status.notInstalled": "未安装", "integrations.status.appliedAt": "已应用", + "integrations.status.supersededStore": "此客户端现在从 {path} 读取提供方,而 opencodex 不写入该文件,因此在这里启用不会改变它加载的内容。", "integrations.status.backup": "备份", "integrations.status.lastRestore": "上次恢复", "integrations.status.unknown": "未知", @@ -1510,6 +1511,7 @@ export const zh: Record = { "integrations.plan.refusal.conflict": "托管设置与当前文件冲突。请检查文件,或在可用时使用“替换”。", "integrations.plan.refusal.unsafe": "无法安全地更改客户端配置。请修复或替换文件后重试。", "integrations.plan.refusal.nonLoopback": "此客户端需要环回代理地址。请使用 localhost、隧道或本地转发后重试。", + "integrations.plan.refusal.supersededStore": "此客户端从 opencodex 不写入的文件读取提供方,因此该操作不会改变它加载的内容。", "integrations.plan.refusal.driftRequiresConfirm": "文件在此回滚点后发生了变化。请先查看包含变化的计划再恢复。", "integrations.plan.refusal.snapshotExpired": "此回滚点的备份已过期,无法再恢复。", "integrations.plan.refusal.writeFailed": "服务器无法准备安全写入。请刷新集成状态后重试。", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 3ee7db1b967..b8806d563df 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -330,6 +330,14 @@ export default function FileIntegrationPage({

{status.configPath}

{/* Only the raycast envelope carries this; the guard is the field, not the id. */} {status.raycast && } + {/* + A file the client no longer opens. The badge above stays truthful about + the file -- our block really is where we put it -- so this is the only + place that can say the client has stopped reading it. + */} + {status.supersededBy && ( + {t("integrations.status.supersededStore", { path: status.supersededBy })} + )} {status.appliedAt && (

diff --git a/gui/src/pages/integrations/IntegrationPlanDetails.tsx b/gui/src/pages/integrations/IntegrationPlanDetails.tsx index 742794de93c..d412282c99a 100644 --- a/gui/src/pages/integrations/IntegrationPlanDetails.tsx +++ b/gui/src/pages/integrations/IntegrationPlanDetails.tsx @@ -35,6 +35,7 @@ const REFUSAL_KEYS: Partial> = { conflict: "integrations.plan.refusal.conflict", unsafe: "integrations.plan.refusal.unsafe", non_loopback: "integrations.plan.refusal.nonLoopback", + superseded_store: "integrations.plan.refusal.supersededStore", drift_requires_confirm: "integrations.plan.refusal.driftRequiresConfirm", snapshot_expired: "integrations.plan.refusal.snapshotExpired", write_failed: "integrations.plan.refusal.writeFailed", diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 856a9b105d0..02733e095ce 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -36,6 +36,7 @@ export type IntegrationRefusalReason = | "conflict" | "unsafe" | "non_loopback" + | "superseded_store" | "drift_requires_confirm" | "snapshot_expired" | "write_failed"; @@ -61,6 +62,14 @@ export interface IntegrationStatus { appliedAt?: string; lastOpId?: string; reason?: IntegrationReason; + /** + * The store this client reads instead of `configPath`, when one exists. + * + * Independent of `state`: the block can be current in a file the client + * stopped opening, which is the one case where a green badge alone misleads. + * Same role as `raycast`, whose plan can make a written file inert. + */ + supersededBy?: string; snapshotCount: number; retentionDegraded: boolean; /** Aside's explicit account-backed profile scope and desired sync state. */ @@ -194,6 +203,7 @@ const REFUSAL_REASONS: ReadonlySet = new Set([ "conflict", "unsafe", "non_loopback", + "superseded_store", "drift_requires_confirm", "snapshot_expired", "write_failed", @@ -228,6 +238,10 @@ const PLAN_SCHEMA_PATHS = new Set([ "providers.[id=opencodex]", "settings.providers.opencodex", "catalog.providers.opencodex", + // ZCode reads its providers from a second file; a plan for it publishes that + // file's templates, and a path missing here is rejected as an invalid preview. + "config.providerConfigRules.providerRules.[providerId=opencodex]", + "config.modelConfigRules.providerModelRules.*", ]); const PLAN_CHANGE_LIMIT = 256; @@ -254,7 +268,13 @@ export function parseIntegrationMutationPlan(value: unknown): IntegrationMutatio || !PLAN_OPERATIONS.includes(value.operation as IntegrationPlanOperation) || !INTEGRATION_STATES.has(String(value.state)) || !PLAN_FOREIGN_EDITS.includes(value.foreignEdit as IntegrationPlanForeignEdit) - || typeof value.fingerprint !== "string" || !/^p1:(?:[0-9a-f]{32}|unbound)$/.test(value.fingerprint) + /* + * The version is matched as a version, not as `p1`. The server calls this + * token opaque and bumps its prefix whenever the inputs it binds change; a + * literal here made that bump a silent client-side rejection of every + * preview, which is a worse failure than the drift it was meant to catch. + */ + || typeof value.fingerprint !== "string" || !/^p[0-9]+:(?:[0-9a-f]{32}|unbound)$/.test(value.fingerprint) || typeof value.canApply !== "boolean" || typeof value.willChange !== "boolean" || !Array.isArray(value.changes) || value.changes.length > PLAN_CHANGE_LIMIT || (value.profileId !== undefined && (typeof value.profileId !== "number" || !Number.isSafeInteger(value.profileId) || value.profileId < 0)) @@ -283,7 +303,7 @@ export function parseIntegrationMutationPlan(value: unknown): IntegrationMutatio } if ((value.willChange && (!value.canApply || changes.length === 0)) || (!value.willChange && changes.length !== 0) - || (value.fingerprint === "p1:unbound" && value.canApply) + || ((value.fingerprint as string).endsWith(":unbound") && value.canApply) || (value.canApply === (value.refusalReason !== undefined))) throw invalidPreviewResponse(); return { version: 1, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b3629cb16ec..6b8712e526f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -842,11 +842,13 @@ "injection-model-api.test.ts": "codex-integration", "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", + "integrations-current-store.test.ts": "clients", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", + "integrations-superseded-store.test.ts": "clients", "integrations-writer-frozen-config.test.ts": "clients", "integrations-writer.test.ts": "clients", "interactive-confirm.test.ts": "cli", diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 5b690a6c8b5..acfda37b4fa 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -229,7 +229,13 @@ export async function handleClientIntegrationCommand( ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`) : [String((result as { error?: string }).error ?? "No Aside profiles found.")] : rows - ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) + /* + * `supersededBy` is named here and not only in the single-client view + * because this list is where a user looks to see that everything is + * connected, and "current" alone is exactly the reassurance that hid a + * client reading a file opencodex does not write. + */ + ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}${row.supersededBy ? " (client reads another file)" : ""}`) : singleClientStatusLines(result)); return; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index af044066308..e19788efe35 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -497,6 +497,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ details: [ "Alias of ocx integration client --client zcode.", "enable writes the managed provider.opencodex block into ~/.zcode/v2/config.json; disable removes only that block.", + "ZCode 3.14 moved its providers to ~/.zcode/v2/provider_config.json; where that file exists, enable is refused because the write cannot reach the client.", "ZCode reads its config at startup — restart ZCode after enable/disable.", "Select OpenCodex Proxy// from ZCode's model picker.", ], diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 13dc2c2964a..90fe5030563 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -35,6 +35,18 @@ export { OPENCODE_PROVIDER_ID, OPENCODE_CONFIG_SCHEMA, OPENCODE_API_KEY_ENV, OPE export { normalizeExportModels } from "./config-export/model-metadata"; export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./config-export/omp"; export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode"; +export type { ZcodeStoreProviderRule, ZcodeStoreModelRule } from "./config-export/zcode-store"; +export { + ZCODE_STORE_SCHEMA_VERSION, + ZCODE_STORE_PROVIDER_GROUP, + ZCODE_STORE_API_TYPE, + ZCODE_STORE_PROVIDER_NAME, + ZCODE_STORE_PROVIDER_RULES_PATH, + ZCODE_STORE_MODEL_RULES_PATH, + buildZcodeStoreProviderRule, + buildZcodeStoreContribution, + zcodeStoreSchemaEstablished, +} from "./config-export/zcode-store"; export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh"; export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast"; @@ -447,6 +459,30 @@ export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(zcodeHomeDir(env, home), "v2", "config.json"); } +/** + * The provider store a current ZCode reads, which is NOT the file above. + * + * ZCode 3.14 moved custom providers to `v2/provider_config.json` and left + * `v2/config.json` reachable only through a one-shot import that runs when the + * new file is missing. The client creates the new file on first launch, so on + * an install that has ever run, the import has already happened and never runs + * again — every later write to `v2/config.json` is read by nobody (#5348). + * + * This project does not write this file; it names it so the integration can + * tell whether its own write can still reach the client. The env override is + * ZCode's own (`ZCODE_PERSONAL_PROVIDER_CONFIG_FILE`), so an operator who + * relocated the store is measured against the file their client actually opens + * rather than the default location. A relative override is refused for the same + * reason `ZCODE_DATA_DIR` refuses one: we and the client would disagree about + * which file it names, and here that disagreement decides whether an apply is + * reported as effective. + */ +export function zcodeProviderStorePath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const override = env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE?.trim(); + if (override) return absoluteClientPath(override, home, "ZCODE_PERSONAL_PROVIDER_CONFIG_FILE"); + return join(zcodeHomeDir(env, home), "v2", "provider_config.json"); +} + /** * Prime Agent resolves its agent directory from `PRIME_AGENT_CODING_AGENT_DIR` * — the brand-derived spelling of the `PI_CODING_AGENT_DIR` that `ompAgentDir` diff --git a/src/clients/config-export/zcode-store.ts b/src/clients/config-export/zcode-store.ts new file mode 100644 index 00000000000..9c88c783d38 --- /dev/null +++ b/src/clients/config-export/zcode-store.ts @@ -0,0 +1,154 @@ +// ZCode personal provider store export. +import type { ExportContext, ManagedContribution, ManagedFragment } from "./contracts"; +import { authoritativeContextWindow, inputModalitiesForClient, normalizeExportModels } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; + +/** + * The file ZCode 3.14 and later actually read their custom providers from. + * + * `v2/config.json` — the file {@link buildZcodeContribution} writes — is still + * parsed by this client, but only by a one-shot `importLegacy` hook that runs + * when this store is missing. The client creates the store on first launch, so + * on any install that has ever run the import is already spent and a later + * write to the old file is read by nobody (#5348). + * + * The shape below is the client's own, quoted from the report that opened + * #5348: it is what the client's `importLegacyPersonalProviderConfig` produced + * when it migrated this project's block, so it is observed output rather than a + * schema this project invented. Everything the client persists that we have not + * seen it derive from our block is deliberately absent — an omitted field is a + * field the client still owns. + */ +export const ZCODE_STORE_SCHEMA_VERSION = 1; + +/** + * `group` places the provider among the user's own personal providers rather + * than the builtin list the client ships and rewrites. `api.type` is the same + * choice `kind: "openai"` makes in the legacy block: the OpenAI Responses + * protocol, which is the only surface this proxy speaks natively. + */ +export const ZCODE_STORE_PROVIDER_GROUP = "standard-personal"; +export const ZCODE_STORE_API_TYPE = "openai-responses"; +export const ZCODE_STORE_PROVIDER_NAME = "OpenCodex"; + +/** Where the store keeps one rule per provider, and one per (provider, model). */ +export const ZCODE_STORE_PROVIDER_RULES_PATH = ["config", "providerConfigRules", "providerRules"] as const; +export const ZCODE_STORE_MODEL_RULES_PATH = ["config", "modelConfigRules", "providerModelRules"] as const; + +export interface ZcodeStoreProviderRule { + providerId: string; + enabled: true; + providerName: string; + config: { + group: string; + access: { apiKey: string }; + api: { type: string; baseUrl: string }; + personalModelIds: string[]; + modelOrder: string[]; + }; +} + +export interface ZcodeStoreModelRule { + providerId: string; + modelId: string; + config: { properties: { contextWindow: number } }; +} + +/** + * Is this document a store whose schema we can write? + * + * Only the one version whose shape has been observed. A store carrying any + * other `schemaVersion` — or none, or a document that is not an object — is a + * file we cannot merge into without asserting a nesting we have never seen, + * and a wrong assertion there does not fail loudly: it replaces the provider + * list the user keeps in the same file. The integration reports the write as + * ineffective in that case instead of guessing. + */ +export function zcodeStoreSchemaEstablished(parsed: unknown): boolean { + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false; + return (parsed as Record).schemaVersion === ZCODE_STORE_SCHEMA_VERSION; +} + +/** + * The selector addressing one model rule, or null when the id cannot be spelled. + * + * A rule is identified by the PAIR, so both fields are named: a selector + * carrying only the model would match another provider's rule for the same + * model. The conjunction grammar reserves `,` and `]`, and a model id holding + * either cannot be addressed unambiguously — that row ships without its + * context window rather than with a selector that points somewhere else. + */ +function modelRuleSelector(modelId: string): string | null { + if (modelId.includes(",") || modelId.includes("]")) return null; + return `[providerId=${OPENCODE_PROVIDER_ID},modelId=${modelId}]`; +} + +/** The rows this export publishes, filtered exactly as the legacy block filters them. */ +function storeModels(ctx: ExportContext): string[] { + const ids: string[] = []; + for (const model of normalizeExportModels(ctx.models)) { + if (inputModalitiesForClient("pi", model.inputModalities) === null) continue; + ids.push(model.namespaced); + } + return ids; +} + +/** + * The provider rule the client reads, built from the same context the legacy + * block is built from. + * + * `baseUrl` carries the `/v1` root for the same reason the legacy block does: + * this client appends `/responses` for the Responses protocol, so requests land + * on `/v1/responses`. The serialized credential is always the non-secret + * loopback placeholder. + */ +export function buildZcodeStoreProviderRule(ctx: ExportContext): ZcodeStoreProviderRule { + const ids = storeModels(ctx); + return { + providerId: OPENCODE_PROVIDER_ID, + enabled: true, + providerName: ZCODE_STORE_PROVIDER_NAME, + config: { + group: ZCODE_STORE_PROVIDER_GROUP, + access: { apiKey: LOOPBACK_API_KEY_PLACEHOLDER }, + api: { type: ZCODE_STORE_API_TYPE, baseUrl: `${ctx.baseUrl.replace(/\/v1\/?$/, "")}/v1` }, + personalModelIds: ids, + /* + * The client persists the picker order separately from membership. Ours is + * the catalog order the rest of this export already sorts by, so a refresh + * that adds a model puts it where every other surface puts it. + */ + modelOrder: [...ids], + }, + }; +} + +/** + * Everything this project owns inside the store: one provider rule, plus one + * model rule per row that has an authoritative context window. + * + * A row without one ships no model rule at all, which is the same + * authoritative-window rule the legacy block follows — an emitted stand-in + * would be a guessed capability. Reasoning ladders have no counterpart here + * that this project has observed the client produce, so none is written. + */ +export function buildZcodeStoreContribution(ctx: ExportContext): ManagedContribution { + const fragments: ManagedFragment[] = [{ + path: [...ZCODE_STORE_PROVIDER_RULES_PATH, `[providerId=${OPENCODE_PROVIDER_ID}]`], + value: buildZcodeStoreProviderRule(ctx), + }]; + for (const model of normalizeExportModels(ctx.models)) { + if (inputModalitiesForClient("pi", model.inputModalities) === null) continue; + const contextWindow = authoritativeContextWindow(model.contextWindow); + if (contextWindow === undefined) continue; + const selector = modelRuleSelector(model.namespaced); + if (selector === null) continue; + const rule: ZcodeStoreModelRule = { + providerId: OPENCODE_PROVIDER_ID, + modelId: model.namespaced, + config: { properties: { contextWindow } }, + }; + fragments.push({ path: [...ZCODE_STORE_MODEL_RULES_PATH, selector], value: rule }); + } + return { clientId: "zcode", fragments }; +} diff --git a/src/integrations/merge.ts b/src/integrations/merge.ts index ab2099b4242..23b8b14f815 100644 --- a/src/integrations/merge.ts +++ b/src/integrations/merge.ts @@ -22,20 +22,56 @@ function clone(value: T): T { /** * `[field=value]` addresses the ONE element of a sequence whose `field` equals - * `value`. Raycast keeps its providers as a YAML list, so the element is the + * `value`, and `[field=value,field=value]` the one whose every named field + * matches. Raycast keeps its providers as a YAML list, so the element is the * smallest thing we can own there; an index would move under us the moment * the user reordered their own entries. Any other segment is a plain key. + * + * A conjunction exists because one field is not always the identity. ZCode + * keys a model rule in its provider store by the PAIR `(providerId, modelId)`, + * so a `[modelId=…]` selector alone would match another provider's rule for the + * same model and then replace it, in a file holding every provider the user + * has. Addressing an element by less than what identifies it is the same + * defect as addressing it by index. + * + * The single-criterion spelling keeps its original grammar, where the value may + * itself contain a comma. A conjunction is recognized only when EVERY + * comma-separated part is `field=value` with a comma-free value, so no path + * already written into an ownership record on disk changes meaning. */ const ARRAY_SELECTOR = /^\[([A-Za-z_][A-Za-z0-9_]*)=([^\]]+)\]$/u; +const SELECTOR_CONJUNCTION = + /^\[([A-Za-z_][A-Za-z0-9_]*=[^,\]]+(?:,[A-Za-z_][A-Za-z0-9_]*=[^,\]]+)+)\]$/u; + +/** One `field=value` equality a selector requires of the element it names. */ +export interface SelectorCriterion { + field: string; + value: string; +} export type PathSegment = | { kind: "key"; key: string } - | { kind: "select"; field: string; value: string }; + | { kind: "select"; criteria: readonly SelectorCriterion[] }; export function parseSegment(raw: string): PathSegment { + const conjunction = SELECTOR_CONJUNCTION.exec(raw); + if (conjunction) { + return { + kind: "select", + criteria: conjunction[1]!.split(",").map(part => { + const equals = part.indexOf("="); + return { field: part.slice(0, equals), value: part.slice(equals + 1) }; + }), + }; + } const match = ARRAY_SELECTOR.exec(raw); if (!match) return { kind: "key", key: raw }; - return { kind: "select", field: match[1]!, value: match[2]! }; + return { kind: "select", criteria: [{ field: match[1]!, value: match[2]! }] }; +} + +/** The `field=value` list a selector segment spells, for messages and seeding. */ +export function selectorPairs(criteria: readonly SelectorCriterion[]): string { + return criteria.map(criterion => `${criterion.field}=${criterion.value}`).join(", "); } /** @@ -44,19 +80,21 @@ export function parseSegment(raw: string): PathSegment { * this to an `unsafe` refusal instead. */ export class AmbiguousSelectorError extends Error { - constructor(field: string, value: string) { - super(`more than one entry has ${field}=${value}`); + constructor(criteria: readonly SelectorCriterion[]) { + super(`more than one entry has ${selectorPairs(criteria)}`); this.name = "AmbiguousSelectorError"; } } /** The index of the element a selector names, -1 when none matches. */ -export function selectIndex(items: readonly unknown[], field: string, value: string): number { +export function selectIndex(items: readonly unknown[], criteria: readonly SelectorCriterion[]): number { const matches: number[] = []; items.forEach((item, index) => { - if (isPlainRecord(item) && item[field] === value) matches.push(index); + if (isPlainRecord(item) && criteria.every(criterion => item[criterion.field] === criterion.value)) { + matches.push(index); + } }); - if (matches.length > 1) throw new AmbiguousSelectorError(field, value); + if (matches.length > 1) throw new AmbiguousSelectorError(criteria); return matches[0] ?? -1; } @@ -64,6 +102,40 @@ function assertNever(segment: never): never { throw new Error(`unknown path segment ${JSON.stringify(segment)}`); } +/** The element a selector names, or `undefined` when none matches. */ +function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown { + return items[selectIndex(items, segment.criteria)]; +} + +/** + * Read `path` through the same segment grammar `setPath` writes through. + * + * It belongs here rather than beside the classifier because the grammar does: a + * reader that resolved a selector differently from the writer would report one + * element as ours and then rewrite another. `state` re-exports it for the + * callers that have always imported it from there. + */ +export function readPath(doc: unknown, path: readonly string[]): unknown { + let cursor: unknown = doc; + for (const raw of path) { + const segment = parseSegment(raw); + switch (segment.kind) { + case "key": + if (!isPlainRecord(cursor)) return undefined; + cursor = cursor[segment.key]; + break; + case "select": + if (!Array.isArray(cursor)) return undefined; + cursor = selectElement(cursor, segment); + break; + default: + return assertNever(segment); + } + if (cursor === undefined) return undefined; + } + return cursor; +} + /** * Write `value` at `path`, creating intermediate containers. Returns a new document. * @@ -99,7 +171,7 @@ export function setPath(doc: unknown, path: readonly string[], value: unknown): case "select": { if (!Array.isArray(read())) write([]); const items = read() as unknown[]; - const found = selectIndex(items, segment.field, segment.value); + const found = selectIndex(items, segment.criteria); parent = items; if (found >= 0) { slot = found; @@ -107,7 +179,7 @@ export function setPath(doc: unknown, path: readonly string[], value: unknown): // Seed the element so the selector stays true for whatever a deeper // segment writes into it; a last-position select replaces it whole. slot = items.length; - items.push({ [segment.field]: segment.value }); + items.push(Object.fromEntries(segment.criteria.map(criterion => [criterion.field, criterion.value]))); } break; } @@ -155,7 +227,7 @@ export function deletePath( } case "select": { if (!Array.isArray(container)) return { doc: root, removed: false }; - const found = selectIndex(container, segment.field, segment.value); + const found = selectIndex(container, segment.criteria); if (found < 0) return { doc: root, removed: false }; slots.push(found); chain.push(container[found] as Record | unknown[]); @@ -249,7 +321,7 @@ export function createdContainerPaths( case "select": { // A selector that matches nothing means setPath will push the element. next = Array.isArray(cursor) - ? cursor[selectIndex(cursor, segment.field, segment.value)] + ? cursor[selectIndex(cursor, segment.criteria)] : undefined; break; } diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 20cc91c9f6a..7bf3d6fe537 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -17,10 +17,20 @@ import { createHash } from "node:crypto"; import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership"; import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants"; +import { + ZCODE_STORE_MODEL_RULES_PATH, + ZCODE_STORE_PROVIDER_RULES_PATH, +} from "../clients/config-export/zcode-store"; import { createClineIO, ClineTransactionError } from "./cline-io"; import { parseClineDocument } from "./cline-document"; import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; -import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; +import { + INTEGRATION_CLIENTS, + isLoopbackOnly, + resolveIntegrationPaths, + type IntegrationClientId, +} from "./registry"; +import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; @@ -37,6 +47,7 @@ export type RefusalReason = | "conflict" | "unsafe" | "non_loopback" + | "superseded_store" | "drift_requires_confirm" | "snapshot_expired" | "write_failed"; @@ -114,7 +125,16 @@ const CLIENT_MANAGED_PATHS = { gajae: [["providers", OPENCODE_PROVIDER_ID]], dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]], mcode: [["custom_provider", OPENCODE_PROVIDER_ID]], - zcode: [["provider", OPENCODE_PROVIDER_ID]], + zcode: [ + ["provider", OPENCODE_PROVIDER_ID], + /* + * The store the current client reads. The model rule carries the model id in + * its own selector, so the published segment is the dynamic one: the template + * leaves this module, never the observed path. + */ + [...ZCODE_STORE_PROVIDER_RULES_PATH, `[providerId=${OPENCODE_PROVIDER_ID}]`], + [...ZCODE_STORE_MODEL_RULES_PATH, DYNAMIC_SEGMENT], + ], prime: [["providers", OPENCODE_PROVIDER_ID]], aside: [["providers", OPENCODE_PROVIDER_ID]], raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]], @@ -205,6 +225,18 @@ export interface PlanFingerprintInput { * plan that did not bind it could be confirmed after the proxy stopped being a legal target. */ readonly admissionBlocked: boolean; + /** + * Why a write to the target would not reach the client, or null. + * + * Bound for the same reason `installKind` is: it can flip without touching the + * file, the record or the contribution. A client that creates its new store + * while a confirmation is outstanding has changed whether the write can reach + * it, and a plan that did not bind this would still authorize the write. The + * reason travels with the location because both can move on their own: a store + * whose schema version changes under an unchanged path is the same class of + * flip as a store appearing. + */ + readonly ineffectiveWrite: string | null; /** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */ readonly before: string | null; readonly contribution: ManagedContribution | null; @@ -226,7 +258,7 @@ export interface PlanFingerprintInput { }; } -const PLAN_FINGERPRINT_VERSION = "p1"; +const PLAN_FINGERPRINT_VERSION = "p2"; function digest(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 32); @@ -251,6 +283,7 @@ export function planFingerprint(input: PlanFingerprintInput): string { input.detectDir, input.installKind, input.admissionBlocked, + input.ineffectiveWrite, input.before === null ? "\u0000absent" : fingerprint(input.before), input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)), input.record === null ? null : fingerprint(JSON.stringify(input.record)), @@ -305,6 +338,13 @@ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit { function applyOutcome(input: PlanInput): PlanOutcome { if (input.installKind !== "dir") return deny("not_installed"); if (input.admissionBlocked) return deny("non_loopback"); + /* + * Before any file state. The document may be perfectly writable and our block + * may already be current in it; neither says anything about whether the + * client reads it, and reporting a change to a file nobody opens is the + * defect this refusal exists for. + */ + if (input.ineffectiveWrite !== null) return deny("superseded_store"); // Overwrite exists precisely to proceed through a conflict the operator has been shown. if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict"); if (input.classified.state === "unsafe") return deny("unsafe"); @@ -514,9 +554,16 @@ export function observeRestore( return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const; } const configPath = entry.configPath; - // An undo acts on the path the operation was journaled against. A row recorded for one home must - // never be allowed to rewrite a file in another. - if (resolved.configPath !== configPath) { + /* + * An undo acts on the path the operation was journaled against. A row recorded for one home must + * never be allowed to rewrite a file in another — but a client may legally have written more than + * one file, so the test is whether this client still names that location, not whether it is the + * config file. The answer also carries the document shape those bytes are in. + */ + const rowTarget = declaredIntegrationTarget({ + clientId, configPath, resolvedConfigPath: resolved.configPath, env: input.env, home: input.home, + }); + if (rowTarget === null) { return { failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"), } as const; @@ -546,6 +593,7 @@ export function observeRestore( failed: undefined, clientId, configPath, + format: rowTarget.format, detectDir: resolved.detectDir, installKind: io.statKind(resolved.detectDir), entry, @@ -589,6 +637,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe installKind: observed.io.statKind(observed.detectDir), // Loopback-only clients cannot carry the admission header a non-loopback bind requires. admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config), + ineffectiveWrite: observed.ineffectiveWrite, before: observed.before, contribution: observed.contribution, record: observed.record, @@ -659,6 +708,13 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): detectDir: observed.detectDir, installKind: observed.installKind, admissionBlocked: false, + /* + * Undo puts back bytes this project already wrote to this file. Whether the + * client still reads the file does not change whether those bytes may be + * restored, and refusing here would strand a user on a state they asked to + * leave. + */ + ineffectiveWrite: null, before: observed.before, contribution: null, // Descriptive, never decisive. The record says which places are ours now and the document @@ -673,7 +729,7 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): ? {} : observed.clientId === "cline" ? parseClineDocument(observed.before) - : parseConfig(observed.before, EXPORT_CLIENTS[observed.clientId].format), + : parseConfig(observed.before, observed.format), restore: { opId: observed.entry.opId, entry: observed.entry, @@ -757,6 +813,8 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ */ let configPath: string; let detectDir: string; + let effective: IntegrationTarget; + let stored: OwnershipRecord | null; try { /* * Resolve the PAIR, never one half. @@ -768,9 +826,26 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ * 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; - if (clientId === "cline") io = createClineIO(io, configPath, store, effects.recover); + if (clientId === "cline") io = createClineIO(io, resolved.configPath, store, effects.recover); + /* + * A record proves ownership of the file it was written FOR, and it is also + * one of the inputs the target is chosen from: a block we already wrote to + * the config file keeps this operation on that file, so disable removes what + * we wrote from where we wrote it. Matching by path happens after the + * target is known, because that is the path it has to match. + */ + stored = store.readRecords()[clientId] ?? null; + /* + * Inside the same guard as resolution, because this resolver can refuse the + * same way: the store is named by a client env var, and a relative one is a + * misconfiguration to report rather than an exception to leak through the + * collection route. + */ + effective = resolveIntegrationTarget({ + clientId, configPath: resolved.configPath, io, record: stored, env: input.env, home: input.home, + }); + configPath = effective.configPath; } catch (error) { if (error instanceof ClineTransactionError) { return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const; @@ -781,26 +856,31 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ // Pruning writes, so only a mutation may perform it. Preview reports the state it finds. if (effects.maintenance) store.retryPendingPrunes(); - const target = loadTarget(io, configPath); - if (!target.ok) { + const loaded = loadTarget(io, configPath); + if (!loaded.ok) { return { failed: observationFailure("unsafe", "unsafe", - target.why === "read-failed" + loaded.why === "read-failed" ? `${configPath} exists but could not be read` : `${configPath} is not a regular file`), } as const; } - const before = target.before; - const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format); + const before = loaded.before; + const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, effective.format); if (parsed === PARSE_FAILED) { return { failed: observationFailure("unsafe", "unsafe", `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const; } - const contribution = exportSpec.buildContribution(exportContextOf(input)); + /* + * The shape the TARGET's reader understands, which is not always the + * client's config format: a client that moved its providers to another file + * reads a different document there, and a write in the config file's shape + * would be as unread as a write to the config file itself. + */ + const contribution = effective.buildContribution(exportContextOf(input)); // A record proves ownership of the file it was written FOR. Matching only by // client id let a record for one home authorize a write to another whose // bytes happened to hash the same — which deleted a config we never touched. - const stored = store.readRecords()[clientId] ?? null; const record = stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null; @@ -810,6 +890,18 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ // ownership here and disable would delete fragments it never wrote. const classified = classifyIntegration({ fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId, + format: effective.format, }); - return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const; + return { + failed: undefined, store, io, clientId, spec, exportSpec, target: effective, configPath, detectDir, + /* + * One token for the plan, because the location alone is not the input: a + * store whose schema stops being one we recognise changes the answer while + * its path stays exactly the same. + */ + ineffectiveWrite: effective.ineffective === null + ? null + : `${effective.ineffective.why}\u0000${effective.ineffective.store}`, + before, parsed, contribution, record, classified, + } as const; } diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 41813162074..55d87a60ac4 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -43,6 +43,11 @@ import { raycastConfigPath, zcodeConfigPath, zcodeHomeDir, + zcodeProviderStorePath, + buildZcodeStoreContribution, + zcodeStoreSchemaEstablished, + type BuildContribution, + type ConfigFormat, type ExportClientId, } from "../clients/config-export"; @@ -58,6 +63,28 @@ export interface IntegrationClientSpec { configPath: (env?: NodeJS.ProcessEnv, home?: string) => string; /** Directory whose existence is the cheap "is it installed?" signal. */ detectDir: (env?: NodeJS.ProcessEnv, home?: string) => string; + /** + * The provider store this client reads INSTEAD of `configPath`. + * + * A client that moves its store between releases usually keeps a one-shot + * import from the old location, and that import is exactly what makes the old + * write look like it still works: it runs once, on an install that has never + * created the new file, and never again. Everything after it lands in a file + * the client does not open. + * + * A declaration carries everything needed to write the store, not only its + * location: the text format, the contribution shape its reader understands, + * and the predicate that says whether a document on disk is a version whose + * shape has been observed. The last one is what keeps this honest — a store + * we cannot establish is reported as the reason the write cannot reach the + * client, never merged into on a guess. + */ + currentStore?: { + path: (env?: NodeJS.ProcessEnv, home?: string) => string; + format: ConfigFormat; + establishes: (parsed: unknown) => boolean; + buildContribution: BuildContribution; + }; /** Patch only this block-map YAML leaf; never re-render the shared file. */ sourcePreservingYaml?: { path: readonly string[] }; /** Coordinate the complete mutation through a sibling config lock. */ @@ -231,6 +258,17 @@ export const INTEGRATION_CLIENTS: Record zcodeConfigPath(env, home), detectDir: (env = process.env, home = homedir()) => zcodeHomeDir(env, home), + /* + * ZCode 3.14 reads its providers from `v2/provider_config.json` and reaches + * `v2/config.json` only through the import that seeded it. Where the new + * file exists the import is spent, so our write is read by nobody (#5348). + */ + currentStore: { + path: (env = process.env, home = homedir()) => zcodeProviderStorePath(env, home), + format: "json", + establishes: zcodeStoreSchemaEstablished, + buildContribution: buildZcodeStoreContribution, + }, }, prime: { id: "prime", diff --git a/src/integrations/state.ts b/src/integrations/state.ts index c1c90961406..8c26591fe75 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -11,10 +11,11 @@ import { createClineIO } from "./cline-io"; import { parseClineDocument } from "./cline-document"; import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel, type ManagedContribution } from "../clients/config-export"; +import type { ConfigFormat } from "../clients/config-export"; import type { OcxConfig } from "../types"; import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { SNAPSHOT_RETENTION } from "./journal"; -import { AmbiguousSelectorError, parseSegment, selectIndex, type PathSegment } from "./merge"; +import { AmbiguousSelectorError, parseSegment, readPath, selectIndex, type PathSegment } from "./merge"; import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership"; import { protectedContributionFingerprint, @@ -28,6 +29,7 @@ import { unresolvedPathHintFor, type IntegrationClientId, } from "./registry"; +import { resolveIntegrationTarget, type IntegrationTarget } from "./target"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe"; @@ -50,6 +52,21 @@ export interface IntegrationStatus { appliedAt?: string; lastOpId?: string; reason?: StateReason; + /** + * The store this client reads instead of `configPath`. + * + * Present only when the integration is NOT writing that store: either our + * block is still in the config file, or the store is not a document whose + * shape has been observed. Where the store is written, `configPath` names it + * and there is nothing to report beside the state. + * + * Orthogonal to `state`, which answers "what is on disk, and did we put it + * there?" — and answers it correctly here: the block can be byte-for-byte + * current in a file the client stopped opening. That pair is not a + * contradiction, it is the whole of #5348, so the surface that reports + * `current` has to be able to report this beside it. + */ + supersededBy?: string; /** Snapshot files retained for this client; -1 when they cannot be inspected. */ snapshotCount: number; /** Pruning is behind, so older (possibly credential-bearing) snapshots remain. */ @@ -64,37 +81,14 @@ function assertNever(segment: never): never { throw new Error(`unknown path segment ${JSON.stringify(segment)}`); } -/** The element a selector names, or `undefined` when none matches. */ -function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown { - return items[selectIndex(items, segment.field, segment.value)]; -} - /** - * Same segment grammar as `setPath`: a plain key reads through a record, a - * `[field=value]` selector reads through an array. Because the classifier and - * the writer share this one function, status and mutation cannot disagree - * about which element is ours. + * The path reader lives with the path grammar, in `merge`, and is re-exported + * here because the classifier was its original home and every caller imports + * it from this module. One implementation is the point: a reader that resolved + * a selector differently from the writer would report one element as ours and + * then rewrite another. */ -export function readPath(doc: unknown, path: readonly string[]): unknown { - let cursor: unknown = doc; - for (const raw of path) { - const segment = parseSegment(raw); - switch (segment.kind) { - case "key": - if (!isPlainRecord(cursor)) return undefined; - cursor = cursor[segment.key]; - break; - case "select": - if (!Array.isArray(cursor)) return undefined; - cursor = selectElement(cursor, segment); - break; - default: - return assertNever(segment); - } - if (cursor === undefined) return undefined; - } - return cursor; -} +export { readPath }; /** Does the document carry any fragment we would write? */ export function hasOurFragments(doc: unknown, contribution: ManagedContribution): boolean { @@ -136,7 +130,7 @@ export function blockedContainerPath( case "key": return (value as Record)[segment.key]; case "select": - return selectElement(value as readonly unknown[], segment); + return (value as readonly unknown[])[selectIndex(value as readonly unknown[], segment.criteria)]; default: return assertNever(segment); } @@ -285,6 +279,14 @@ export function classifyIntegration(input: { */ configPath?: string; clientId?: IntegrationClientId; + /** + * Text format of the file being classified, which is not always the client's + * config format: a client that moved its providers keeps a second document + * whose format is declared with the store. Only the comment-capability of the + * format is read here, and reading the wrong one would decide a sibling edit + * the wrong way. + */ + format?: ConfigFormat; }): { state: IntegrationState; reason?: StateReason } { if (input.fileText !== null && !input.fileIsRegular) { return { state: "unsafe", reason: "not-regular-file" }; @@ -393,7 +395,7 @@ export function classifyIntegration(input: { * stands and re-owns the file. This also lets disable proceed on a * drifted file — removal still touches only the recorded fragment paths. */ - if (EXPORT_CLIENTS[clientId].format !== "json") { + if ((input.format ?? EXPORT_CLIENTS[clientId].format) !== "json") { return { state: "conflict", reason: "foreign-edit" }; } return { state: "stale" }; @@ -491,7 +493,6 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS retryPendingPrunesOnce(store); let io = input.io ?? store.io(); const spec = INTEGRATION_CLIENTS[input.clientId]; - const exportSpec = EXPORT_CLIENTS[input.clientId]; const retention = retentionOf(input.clientId, store); /* * Resolution can refuse — a relative OPENCLAW_* selector names a file whose @@ -499,14 +500,25 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS * every client for its state, so letting that escape would answer 500 for * the whole Integrations page because one client is misconfigured. */ - let configPath: string; let installed: boolean; + let effective: IntegrationTarget; + let record: OwnershipRecord | null; try { // 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 = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home); - configPath = paths.configPath; installed = io.statKind(paths.detectDir) === "dir"; + if (input.clientId === "cline") io = createClineIO(io, paths.configPath, store); + /* + * The record is one of the inputs the target is chosen from, so it is read + * here rather than after the file. The status this function reports is about + * whichever file the next mutation would act on; reading a different one + * would let the badge and the switch disagree. + */ + record = store.readRecords()[input.clientId] ?? null; + effective = resolveIntegrationTarget({ + clientId: input.clientId, configPath: paths.configPath, io, record, env: input.env, home: input.home, + }); } catch (error) { if (!(error instanceof ClientPathError)) throw error; /* @@ -531,30 +543,32 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS }; } - if (input.clientId === "cline") io = createClineIO(io, configPath, store); - const target = loadTarget(io, configPath); - if (!target.ok) { + const configPath = effective.configPath; + const loaded = loadTarget(io, configPath); + if (!loaded.ok) { return { clientId: input.clientId, state: "unsafe", installed, configPath, - reason: target.why === "read-failed" ? "unparseable" : "not-regular-file", + reason: loaded.why === "read-failed" ? "unparseable" : "not-regular-file", ...retention, }; } - const parsed = input.clientId === "cline" ? parseClineDocument(target.before) : parseConfig(target.before, exportSpec.format); - const contribution = exportSpec.buildContribution(exportContextOf(input)); - const record = store.readRecords()[input.clientId] ?? null; + const parsed = input.clientId === "cline" + ? parseClineDocument(loaded.before) + : parseConfig(loaded.before, effective.format); + const contribution = effective.buildContribution(exportContextOf(input)); const { state, reason } = classifyIntegration({ - fileText: target.before, + fileText: loaded.before, fileIsRegular: true, parsed, record, contribution, configPath, clientId: input.clientId, + format: effective.format, }); return { @@ -563,6 +577,13 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS installed, configPath, ...(reason ? { reason } : {}), + /* + * Only when the client reads a DIFFERENT file than the one this status is + * about. A store we are writing needs no notice; the path already names it. + */ + ...(effective.ineffective && effective.ineffective.store !== configPath + ? { supersededBy: effective.ineffective.store } + : {}), ...(record ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}), ...retention, }; diff --git a/src/integrations/target.ts b/src/integrations/target.ts new file mode 100644 index 00000000000..a8ce14090bc --- /dev/null +++ b/src/integrations/target.ts @@ -0,0 +1,205 @@ +/** + * Which file an integration operation reads, writes and records — and, when + * that is not the file the client reads, which file is. + * + * A client may move its provider list between releases and keep the old file + * reachable only through a one-shot import. Everything the integration checks + * still passes against the old file: it is writable, the block merges, the + * ownership record describes it correctly. The only thing that changed is + * whether anything reads it (#5348). + * + * So the target is chosen before anything is classified, and it is chosen by + * three facts in this order: + * + * 1. No declared store, or no store on disk — the config file, exactly as + * before. A client that has never run still imports what we write there. + * 2. Our own block already sits in the config file this record names. Stay + * there. Disable removes what we wrote from where we wrote it, and the + * caller reports the write as ineffective rather than orphaning a block in + * one file while writing another. + * 3. Otherwise the store, but only when its schema can be established. A store + * we cannot parse or whose version we have not observed is reported as the + * reason the write cannot reach the client, because merging an invented + * nesting into a file holding the user's other providers would trade a + * silent no-op for a silent loss. + */ +import { EXPORT_CLIENTS, ClientPathError, type BuildContribution, type ConfigFormat } from "../clients/config-export"; +import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; +import { AmbiguousSelectorError, readPath } from "./merge"; +import type { OwnershipRecord } from "./ownership"; +import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry"; + +export interface IntegrationTarget { + /** The file this operation reads, writes, journals and records ownership for. */ + readonly configPath: string; + /** Text format of that file, which is not always the client's config format. */ + readonly format: ConfigFormat; + /** The contribution shape that file's reader understands. */ + readonly buildContribution: BuildContribution; + /** + * Set when a write to the file above would not reach the client. + * + * `why` is carried rather than re-derived because the cases have different + * remedies and only this function knows which one it took. Disable is not + * gated on it: removing bytes this project wrote from the file it wrote them + * to is as effective as it ever was. + */ + readonly ineffective: IneffectiveWrite | null; +} + +export interface IneffectiveWrite { + /** The provider store the client reads. */ + readonly store: string; + /** + * `owned-config-file` — this project's block is still in the config file, so + * the operation stays there and the remedy is to remove it first, rather than + * leave a block in one file while writing another. + * + * `unestablished-schema` — the store is not a document whose shape has been + * observed, so there is nothing safe to merge into it. This one is reachable + * with the store itself as the target: a client that bumps its schema after + * we wrote the store leaves our block there, removable, and the file no + * longer one we may merge into. + */ + readonly why: "owned-config-file" | "unestablished-schema"; +} + +function configFileTarget( + clientId: IntegrationClientId, + configPath: string, + ineffective: IneffectiveWrite | null, +): IntegrationTarget { + const exportSpec = EXPORT_CLIENTS[clientId]; + return { + configPath, + format: exportSpec.format, + buildContribution: exportSpec.buildContribution, + ineffective, + }; +} + +function storeTarget( + declared: NonNullable<(typeof INTEGRATION_CLIENTS)[IntegrationClientId]["currentStore"]>, + configPath: string, + ineffective: IneffectiveWrite | null = null, +): IntegrationTarget { + return { + configPath, + format: declared.format, + buildContribution: declared.buildContribution, + ineffective, + }; +} + +/** + * Does the file this record names still hold any fragment it claims? + * + * Uncertainty answers yes. An unreadable or unparseable config file is a state + * the classifier is about to refuse on, and it must refuse on the file our + * record is about rather than silently move the operation to a different one. + * An ambiguous selector is the same kind of answer. + */ +function recordedBlockStillPresent( + io: IntegrationIO, + format: ConfigFormat, + configPath: string, + record: OwnershipRecord, +): boolean { + const loaded = loadTarget(io, configPath); + if (!loaded.ok) return true; + const parsed = parseConfig(loaded.before, format); + if (parsed === PARSE_FAILED) return true; + try { + return record.fragmentPaths.some(path => readPath(parsed, path) !== undefined); + } catch (error) { + if (error instanceof AmbiguousSelectorError) return true; + throw error; + } +} + +/** + * Resolve the target for one operation. + * + * `configPath` is the client's config file as the registry resolves it now, and + * `record` is the stored ownership as read, before any path filtering — the + * filtering depends on the answer this function returns. + * + * The store is observed through the caller's own `IntegrationIO`, never the + * real filesystem directly, so status and mutation cannot disagree about + * whether a write can land. + */ +export function resolveIntegrationTarget(args: { + clientId: IntegrationClientId; + configPath: string; + io: IntegrationIO; + record: OwnershipRecord | null; + env?: NodeJS.ProcessEnv; + home?: string; +}): IntegrationTarget { + const { clientId, configPath, io, record } = args; + const declared = INTEGRATION_CLIENTS[clientId].currentStore; + if (!declared) return configFileTarget(clientId, configPath, null); + const storePath = declared.path(args.env, args.home); + /* + * Only a regular file. A failed stat is an unreadable path, not evidence + * that the client migrated, and a directory there is not a provider list the + * client loaded — refusing over either would block an apply that would have + * worked. + */ + if (io.statKind(storePath) !== "file") return configFileTarget(clientId, configPath, null); + const loaded = loadTarget(io, storePath); + const parsed = loaded.ok ? parseConfig(loaded.before, declared.format) : PARSE_FAILED; + const established = parsed !== PARSE_FAILED && declared.establishes(parsed); + const unestablished: IneffectiveWrite = { store: storePath, why: "unestablished-schema" }; + /* + * Our own block decides the target before the store's schema does, whichever + * file holds it. Disable has to remove what we wrote from where we wrote it, + * and an apply must never leave a block in one file while writing another. + */ + const owned = record !== null && record.clientId === clientId ? record : null; + if (owned?.configPath === storePath && recordedBlockStillPresent(io, declared.format, storePath, owned)) { + return storeTarget(declared, storePath, established ? null : unestablished); + } + if ( + owned?.configPath === configPath + && recordedBlockStillPresent(io, EXPORT_CLIENTS[clientId].format, configPath, owned) + ) { + return configFileTarget(clientId, configPath, { store: storePath, why: "owned-config-file" }); + } + if (!established) { + return configFileTarget(clientId, configPath, unestablished); + } + return storeTarget(declared, storePath); +} + +/** + * The target a path NAMES, or null when this client may not write there. + * + * Undo acts on the file its journal row recorded, so it needs the meaning of a + * historical path rather than the choice above: which contribution shape + * describes those bytes, and whether this client still resolves that location + * at all. Resolving a path the client no longer names is what would let a row + * recorded for one home delete a file in another, so an unrecognised path is + * null and the caller refuses. + */ +export function declaredIntegrationTarget(args: { + clientId: IntegrationClientId; + configPath: string; + resolvedConfigPath: string; + env?: NodeJS.ProcessEnv; + home?: string; +}): IntegrationTarget | null { + const { clientId, configPath, resolvedConfigPath } = args; + if (configPath === resolvedConfigPath) return configFileTarget(clientId, configPath, null); + const declared = INTEGRATION_CLIENTS[clientId].currentStore; + if (!declared) return null; + try { + if (declared.path(args.env, args.home) !== configPath) return null; + } catch (error) { + // A store the operator relocated with a path we cannot resolve proves + // nothing about the row, so the row is not a legal target. + if (error instanceof ClientPathError) return null; + throw error; + } + return storeTarget(declared, configPath); +} diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index fa783c37057..262b7a235a7 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -13,7 +13,7 @@ import { homedir } from "node:os"; import { createClineIO, ClineTransactionError } from "./cline-io"; import { serializeClineDocument, preserveClineSelection } from "./cline-document"; import { dirname } from "node:path"; -import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; +import type { ExportModel, ManagedContribution } from "../clients/config-export"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import { detachedConfigSnapshot } from "../config/admitted-identity"; import { copyPlainData } from "../lib/plain-data"; @@ -33,6 +33,7 @@ import { } from "./ownership-policy"; import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge"; import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; +import { declaredIntegrationTarget } from "./target"; import { exportContextOf } from "./state"; import type { IntegrationState } from "./state"; import { serializeDocument, UnserializableValueError } from "./serialize"; @@ -251,7 +252,7 @@ function applyOrRefreshIntegration( ): WriteOutcome { const pre = preflight(input); if (pre.failed) return pre.failed; - const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre; + const { store, io, clientId, spec, target, configPath, detectDir, before, parsed, contribution, record, classified } = pre; // The detect directory preflight already resolved, so it cannot name a // different account than the config path this operation is about to write. @@ -262,6 +263,35 @@ function applyOrRefreshIntegration( return refuse(clientId, "non_loopback", classified.state, `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`); } + /* + * The write would land, and nothing would read it. + * + * This is deliberately a refusal rather than a warning attached to a success. + * The file is writable, our block merges cleanly, and the ownership record + * that follows would describe a real state of a real file — which is exactly + * how the original defect stayed invisible: every check the integration runs + * passed, the journal recorded a correct apply, and no model ever appeared in + * the client (#5348). Reporting the operation as done is the part that is + * wrong, so the operation does not report at all. + * + * Reached only when the store cannot be written: either this project's block + * is still in the config file, where removing it is the way forward, or the + * store is not a document whose shape has been observed. Where the store can + * be written the operation targets it and never arrives here. + * + * Apply, overwrite and refresh only. Disable removes bytes this project put + * in this file, and that removal is as effective as it ever was. + */ + if (target.ineffective !== null) { + const readsFrom = target.ineffective.store; + const fallback = `Add the proxy as a provider in ${clientId}'s own settings instead; \`ocx export --client ${clientId}\` prints the model list to copy.`; + return refuse(clientId, "superseded_store", classified.state, + target.ineffective.why === "owned-config-file" + ? `${clientId} now reads its providers from ${readsFrom}, and opencodex still has a block in ${configPath}, which it no longer reads. Disable the ${clientId} integration to remove that block, then enable it again to write ${readsFrom}.` + : readsFrom === configPath + ? `opencodex does not recognise the schema of ${readsFrom}, the file ${clientId} reads its providers from, so it will not merge into it. ${fallback}` + : `${clientId} now reads its providers from ${readsFrom}, whose schema opencodex does not recognise, so writing ${configPath} would change nothing it loads. ${fallback}`); + } if (classified.state === "conflict") { if (conflictPolicy === "refuse") { return refuse(clientId, "conflict", "conflict", @@ -372,7 +402,7 @@ function applyOrRefreshIntegration( } text = patched; } else { - text = clientId === "cline" ? serializeClineDocument(nextDocument) : serializeDocument(nextDocument, exportSpec.format); + text = clientId === "cline" ? serializeClineDocument(nextDocument) : serializeDocument(nextDocument, target.format); } } catch (error) { if (error instanceof AmbiguousSelectorError) { @@ -460,7 +490,7 @@ export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome { export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { const pre = preflight(input); if (pre.failed) return pre.failed; - const { store, io, clientId, spec, exportSpec, configPath, before, parsed, record, classified } = pre; + const { store, io, clientId, spec, target, configPath, before, parsed, record, classified } = pre; if (classified.state === "absent") { return { ok: true, changed: false, state: "absent", clientId, message: "not applied" }; @@ -532,7 +562,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { } text = patched; } else { - text = clientId === "cline" ? serializeClineDocument(doc) : serializeDocument(doc, exportSpec.format); + text = clientId === "cline" ? serializeClineDocument(doc) : serializeDocument(doc, target.format); } } catch (error) { if (!(error instanceof UnserializableValueError)) throw error; @@ -570,13 +600,21 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome const clientId = entry.clientId; const resolvedPath = input.resolvedPaths?.configPath ?? INTEGRATION_CLIENTS[clientId].configPath(input.env, input.home); - // Restore acts on the path the operation was journaled against. Resolving a - // different path here would let an operation recorded for one home delete a - // file in another. const configPath = entry.configPath; - if (resolvedPath !== configPath) { + /* + * Restore acts on the path the operation was journaled against. Resolving a + * different path here would let an operation recorded for one home delete a + * file in another — but a client may legally have written more than one file, + * so the question is whether this client still names that location, not + * whether it is the config file. The answer also carries the contribution + * shape those bytes are in, which is what the state below is measured against. + */ + const rowTarget = declaredIntegrationTarget({ + clientId, configPath, resolvedConfigPath: resolvedPath, env: input.env, home: input.home, + }); + if (rowTarget === null) { return refuse(clientId, "conflict", "conflict", - `that operation was recorded for ${configPath}, but this client now resolves to ${resolvedPath}`); + `that operation was recorded for ${configPath}, which this client no longer writes; it now resolves to ${resolvedPath}`); } if (clientId === "cline") { try { io = createClineIO(io, configPath, store, true); } @@ -625,7 +663,7 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome // exact bytes when the snapshot was taken. Re-deriving it from the file would // mean guessing which entries are ours, and a wrong guess deletes a user's. const restoredRecord = entry.priorRecord; - const fresh = EXPORT_CLIENTS[clientId].buildContribution(exportContextOf(input)); + const fresh = rowTarget.buildContribution(exportContextOf(input)); /* * Does the restored record actually describe the restored bytes? * diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 5e674c73ed0..379401fd225 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -19,7 +19,8 @@ parsing and ownership rules below. | Module | Responsibility | | --- | --- | | `src/clients/config-export.ts` | Pure per-client config builders and the exact managed fragments each client receives. It never writes files. | -| `src/integrations/registry.ts` | Canonical config/detection paths, source-preserving YAML declarations, writer-lock behavior, and client IDs. | +| `src/integrations/registry.ts` | Canonical config/detection paths, current-provider-store declarations, source-preserving YAML declarations, writer-lock behavior, and client IDs. | +| `src/integrations/target.ts` | Which file one operation reads, writes and records, and whether a write there reaches the client. | | `src/integrations/config-io.ts` | Bounded file loading and parsing. Values that cannot round-trip through the target serializer are rejected before mutation. | | `src/integrations/state.ts` | The single `absent` / `current` / `stale` / `conflict` / `unsafe` classifier used by status and every writer operation. | | `src/integrations/ownership.ts` | Durable ownership records: file, generated contribution, protected contribution, exact fragment paths, and operation identity. | @@ -209,6 +210,60 @@ fail closed. A successful refresh writes the new operation-scoped policy. > Decision record: [ADR-0092](../decisions/ADR-0092-zcode-runtime-metadata.md) +## A store the client no longer reads + +A client may move its provider list to a different file between releases and keep the old one +reachable only through a one-shot import. That import runs on an install that has never created the +new file and never again, so every later write to the old path is read by nobody. ZCode 3.14 is the +instance this rule was written for: the apply was correct, the ownership record was correct, the +journal row was correct, and no model appeared in the client. + +A client in that position declares `currentStore` in the registry. The declaration is not only a +location: it carries the text format of that file, the contribution shape its reader understands, +and the predicate that decides whether a document on disk is a version whose shape has been +observed. Naming the store without the last three would be naming a file we cannot write. + +`src/integrations/target.ts` turns that declaration into the one answer every surface uses: which +file this operation reads, writes, journals and records, and whether a write there reaches the +client. It decides from three facts, in order: + +1. No declared store, or no store on disk — the config file, unchanged. A client that has never run + still imports what we write there, which is why the rule keys on the store's presence rather + than on a client version. +2. This project's own block already in one of the two files — that file. Disable removes what we + wrote from where we wrote it, and no apply leaves a block in one file while writing another. +3. Otherwise the store, and only when its schema establishes. + +Four properties are load-bearing: + +- The store is observed through the same `IntegrationIO` seam as the config file, so status and + mutation cannot disagree about which file an operation is about. Only a regular file counts; a + failed stat is not evidence of a migration. +- The ownership record, the journal row and the undo guard all follow the target rather than the + client. A row naming the store is restorable because the guard asks whether this client still + names that location, not whether it is the config file. +- The refusal is bound into the plan fingerprint together with its reason, so a confirmation taken + before the client created its store cannot be committed afterwards — and neither can one taken + before the store's schema version moved under an unchanged path. +- Disable is never gated on it. Removing bytes this project wrote from the file it wrote them to is + unaffected by where the client reads, and refusing it would leave the block unremovable through + the tool. + +Writing the store does not relax ownership anywhere. The store keys a model rule by the pair +`(providerId, modelId)`, so the managed path names both: a selector naming only the model would +match another provider's rule for the same model and replace it. A rule carrying this project's +provider id that no record accounts for — including one the client's own migration created — is a +conflict, and the explicit overwrite remains the only way past it. + +A store whose schema cannot be established is reported, never merged into. That file holds the +user's other providers and the client rewrites it on its own, so asserting a nesting we have not +observed would trade a silent no-op for a silent loss. Status reports the store beside the file +state rather than folding it into the state: `current` remains the truth about the file, and the +notice appears only when the client reads some other file than the one the state is about. + +Deleting the client's store to re-trigger its own import is not implemented and must not be. It +discards every provider the client keeps there. + ## Verification Behavior changes require real writer tests against a temporary home and state store. At minimum, diff --git a/tests/clients/integrations-current-store.test.ts b/tests/clients/integrations-current-store.test.ts new file mode 100644 index 00000000000..99fc2ef567c --- /dev/null +++ b/tests/clients/integrations-current-store.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + OPENCODE_PROVIDER_ID, + ZCODE_STORE_MODEL_RULES_PATH, + ZCODE_STORE_PROVIDER_RULES_PATH, + ZCODE_STORE_SCHEMA_VERSION, + buildZcodeStoreProviderRule, + type ExportModel, +} from "../../src/clients/config-export"; +import { previewIntegration } from "../../src/integrations/mutation-plan"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { exportContextOf, readIntegrationState, readPath } from "../../src/integrations/state"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { + applyIntegration, + disableIntegration, + overwriteIntegration, + refreshIntegration, + restoreIntegration, + type IntegrationWriteInput, +} from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Writing the store the client actually reads (#5348). + * + * The sibling file covers the refusal, which is what happens when this file's + * precondition fails. Here the store carries a schema whose shape has been + * observed, so enable, refresh and disable all act on it — and the properties + * worth pinning are the ones that make that safe rather than merely working: + * a rule the user owns is never taken over, a rule belonging to another + * provider is never matched by ours, and the client's file is never deleted. + * + * Every location comes from the exported schema constants rather than a + * literal, so a change to where the store keeps its rules moves the fixture + * with the source instead of leaving it green against a shape nobody writes. + */ +let home: string; +let store: IntegrationStateStore; + +const TEST_ENV = {} as NodeJS.ProcessEnv; + +const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, +]; + +const MORE_MODELS: ExportModel[] = [ + ...MODELS, + { namespaced: "xai/grok-4-2", provider: "xai", id: "grok-4-2", contextWindow: 256_000 }, +]; + +const CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as unknown as OcxConfig; + +const spec = () => INTEGRATION_CLIENTS.zcode; + +/** The rule paths, spelled the way the contribution spells them. */ +const OUR_PROVIDER_RULE = [...ZCODE_STORE_PROVIDER_RULES_PATH, `[providerId=${OPENCODE_PROVIDER_ID}]`]; +const ourModelRule = (modelId: string) => [ + ...ZCODE_STORE_MODEL_RULES_PATH, + `[providerId=${OPENCODE_PROVIDER_ID},modelId=${modelId}]`, +]; + +beforeEach(() => { + const base = mkdtempSync(join(tmpdir(), "ocx-current-store-")); + home = join(base, "home"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(join(base, "store", "integrations")); +}); + +afterEach(() => { + removeTreeWithRetry(dirname(home)); +}); + +function configPath(): string { + return spec().configPath(TEST_ENV, home); +} + +function storePath(): string { + return spec().currentStore!.path(TEST_ENV, home); +} + +/** Install the client and give it a store the way a first launch would. */ +function installWithStore(contents: unknown = { schemaVersion: ZCODE_STORE_SCHEMA_VERSION, config: {} }): string { + mkdirSync(spec().detectDir(TEST_ENV, home), { recursive: true }); + mkdirSync(dirname(configPath()), { recursive: true }); + const path = storePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(contents, null, 2)}\n`); + return path; +} + +function readStore(): unknown { + return JSON.parse(readFileSync(storePath(), "utf8")); +} + +function input(overrides: Partial = {}): IntegrationWriteInput { + return { clientId: "zcode", models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store, ...overrides }; +} + +describe("writing the provider store the client reads", () => { + test("enable puts the intended provider in the store, and leaves the config file alone", () => { + const path = installWithStore(); + + const result = applyIntegration(input()); + + expect(result.ok).toBe(true); + // The whole point: the rule is in the file the client opens, in the shape + // that file's reader understands. + // Compared against the builder through the same context the writer derives, + // so the base URL is the one `ocx export` composes rather than a literal. + expect(readPath(readStore(), OUR_PROVIDER_RULE)) + .toEqual(buildZcodeStoreProviderRule(exportContextOf(input()))); + expect(readPath(readStore(), ourModelRule("anthropic/claude-opus-4-8"))) + .toMatchObject({ config: { properties: { contextWindow: 200_000 } } }); + // And nothing was written to the file it stopped reading. + expect(existsSync(configPath())).toBe(false); + // Ownership follows the file that was written, so a later disable removes + // from there rather than from the config path. + expect(store.readRecords().zcode?.configPath).toBe(path); + }); + + test("status reports the store as the file this integration is about", () => { + const path = installWithStore(); + expect(applyIntegration(input()).ok).toBe(true); + + const status = readIntegrationState(input()); + + expect(status).toMatchObject({ state: "current", configPath: path }); + // No notice, because there is no second file the client reads instead. + expect(status.supersededBy).toBeUndefined(); + }); + + test("a catalog refresh updates the store rather than reporting nothing to do", () => { + installWithStore(); + expect(applyIntegration(input()).ok).toBe(true); + + const refreshed = refreshIntegration(input({ models: MORE_MODELS })); + + expect(refreshed.ok).toBe(true); + const rule = readPath(readStore(), OUR_PROVIDER_RULE) as { config: { personalModelIds: string[] } }; + expect(rule.config.personalModelIds).toEqual(MORE_MODELS.map(model => model.namespaced).sort()); + expect(readPath(readStore(), ourModelRule("xai/grok-4-2"))).toBeDefined(); + }); + + test("disable leaves nothing this project put in the store, and keeps the file", () => { + const theirProvider = { providerId: "their-provider", enabled: true, providerName: "Theirs", config: {} }; + const theirModel = { + providerId: "their-provider", + // The SAME model id as ours, under a different provider. A selector naming + // only the model would have matched this rule and replaced it. + modelId: "anthropic/claude-opus-4-8", + config: { properties: { contextWindow: 1 } }, + }; + installWithStore({ + schemaVersion: ZCODE_STORE_SCHEMA_VERSION, + config: { + providerConfigRules: { providerRules: [theirProvider] }, + modelConfigRules: { providerModelRules: [theirModel] }, + }, + }); + expect(applyIntegration(input()).ok).toBe(true); + expect(readPath(readStore(), ourModelRule("anthropic/claude-opus-4-8"))).toBeDefined(); + + const disabled = disableIntegration(input()); + + expect(disabled.ok).toBe(true); + expect(readPath(readStore(), OUR_PROVIDER_RULE)).toBeUndefined(); + expect(readPath(readStore(), ourModelRule("anthropic/claude-opus-4-8"))).toBeUndefined(); + // Theirs survives, including the model rule our selector deliberately does + // not match, and the file itself is still the file the client reads. + expect(readPath(readStore(), [...ZCODE_STORE_PROVIDER_RULES_PATH, "[providerId=their-provider]"])) + .toEqual(theirProvider); + expect(readPath(readStore(), [...ZCODE_STORE_MODEL_RULES_PATH, "[providerId=their-provider,modelId=anthropic/claude-opus-4-8]"])) + .toEqual(theirModel); + expect(existsSync(storePath())).toBe(true); + expect(readIntegrationState(input())).toMatchObject({ state: "absent" }); + }); + + test("a rule the user owns is not taken over, and overwrite is the only way past it", () => { + // The shape the client's own migration produces: our provider id, in the + // store, with no ownership record of ours behind it. + const migrated = { + providerId: OPENCODE_PROVIDER_ID, + enabled: false, + providerName: "Hand edited", + config: { personalModelIds: ["kept"] }, + }; + installWithStore({ + schemaVersion: ZCODE_STORE_SCHEMA_VERSION, + config: { providerConfigRules: { providerRules: [migrated] } }, + }); + + const refused = applyIntegration(input()); + + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.reason).toBe("conflict"); + expect(readPath(readStore(), OUR_PROVIDER_RULE)).toEqual(migrated); + + expect(overwriteIntegration(input()).ok).toBe(true); + expect(readPath(readStore(), OUR_PROVIDER_RULE)).toMatchObject({ enabled: true }); + }); + + test("a schema we cannot establish falls back to reporting the write as ineffective", () => { + // One past the version whose shape has been observed. The file is perfectly + // readable; what is missing is any basis for asserting a nesting inside it. + const path = installWithStore({ schemaVersion: ZCODE_STORE_SCHEMA_VERSION + 1, config: {} }); + const before = readFileSync(path, "utf8"); + + const result = applyIntegration(input()); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("superseded_store"); + expect(result.message).toContain(path); + } + expect(readFileSync(path, "utf8")).toBe(before); + expect(existsSync(configPath())).toBe(false); + expect(readIntegrationState(input()).supersededBy).toBe(path); + }); + + test("a block applied before the client moved keeps the operation on that file", () => { + // Applied while the client still read its config file... + mkdirSync(spec().detectDir(TEST_ENV, home), { recursive: true }); + mkdirSync(dirname(configPath()), { recursive: true }); + expect(applyIntegration(input()).ok).toBe(true); + // ...and only then does the client create the store it reads now. + const path = installWithStore(); + + const refused = refreshIntegration(input({ models: MORE_MODELS })); + + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.reason).toBe("superseded_store"); + // Both files, because the remedy names both. + expect(refused.message).toContain(configPath()); + expect(refused.message).toContain(path); + } + // Nothing was orphaned: the store is untouched and our block is still + // where the record says it is, so disable can remove it. + expect(readPath(readStore(), OUR_PROVIDER_RULE)).toBeUndefined(); + expect(readIntegrationState(input())).toMatchObject({ state: "current", configPath: configPath(), supersededBy: path }); + + expect(disableIntegration(input()).ok).toBe(true); + expect(readFileSync(configPath(), "utf8")).not.toContain("opencodex"); + // And now the same switch reaches the client. + expect(applyIntegration(input()).ok).toBe(true); + expect(readPath(readStore(), OUR_PROVIDER_RULE)).toBeDefined(); + }); + + test("an undo of a store apply puts the store back", () => { + const path = installWithStore(); + const before = readFileSync(path, "utf8"); + const applied = applyIntegration(input()); + expect(applied.ok).toBe(true); + if (!applied.ok) return; + + // Undo used to require the row to name the config file, which would have + // made every store operation unrestorable. + const undone = restoreIntegration({ ...input(), opId: applied.opId! }); + + expect(undone.ok).toBe(true); + expect(readFileSync(path, "utf8")).toBe(before); + }); + + test("the plan publishes the managed template, never a model id", () => { + installWithStore(); + + const plan = previewIntegration(input(), { operation: "apply" }); + + expect(plan.canApply).toBe(true); + const paths = plan.changes.map(change => change.path); + expect(paths).toContain(`${ZCODE_STORE_PROVIDER_RULES_PATH.join(".")}.[providerId=${OPENCODE_PROVIDER_ID}]`); + // The model rule carries the model id inside its own selector, so the + // published segment is the dynamic template and the id never leaves. + expect(paths.some(path => path.includes("anthropic/claude-opus-4-8"))).toBe(false); + }); +}); diff --git a/tests/clients/integrations-merge.test.ts b/tests/clients/integrations-merge.test.ts index 6585d9f1e93..1aecc155509 100644 --- a/tests/clients/integrations-merge.test.ts +++ b/tests/clients/integrations-merge.test.ts @@ -38,15 +38,34 @@ function contribution(path: readonly string[], value: unknown = OURS): ManagedCo describe("parseSegment", () => { test("a selector splits into field and value; anything else is a key", () => { - expect(parseSegment("[id=opencodex]")).toEqual({ kind: "select", field: "id", value: "opencodex" }); + expect(parseSegment("[id=opencodex]")) + .toEqual({ kind: "select", criteria: [{ field: "id", value: "opencodex" }] }); expect(parseSegment("[model_id=anthropic/claude-opus-5]")) - .toEqual({ kind: "select", field: "model_id", value: "anthropic/claude-opus-5" }); + .toEqual({ kind: "select", criteria: [{ field: "model_id", value: "anthropic/claude-opus-5" }] }); expect(parseSegment("providers")).toEqual({ kind: "key", key: "providers" }); // Near misses stay keys: a client whose map literally has such a key keeps working. expect(parseSegment("[id=]")).toEqual({ kind: "key", key: "[id=]" }); expect(parseSegment("[=x]")).toEqual({ kind: "key", key: "[=x]" }); expect(parseSegment("[id=x")).toEqual({ kind: "key", key: "[id=x" }); }); + + test("a conjunction names every field, and one comma alone still does not", () => { + expect(parseSegment("[providerId=opencodex,modelId=anthropic/claude-opus-5]")).toEqual({ + kind: "select", + criteria: [ + { field: "providerId", value: "opencodex" }, + { field: "modelId", value: "anthropic/claude-opus-5" }, + ], + }); + /* + * The old grammar let a single value contain a comma, and ownership records + * on disk are written in that grammar. Only a string whose every part is + * `field=value` becomes a conjunction, so an existing recorded path keeps + * addressing the element it always addressed. + */ + expect(parseSegment("[name=Acme, Inc.]")) + .toEqual({ kind: "select", criteria: [{ field: "name", value: "Acme, Inc." }] }); + }); }); describe("setPath with a selector", () => { diff --git a/tests/clients/integrations-superseded-store.test.ts b/tests/clients/integrations-superseded-store.test.ts new file mode 100644 index 00000000000..4ee1c3c3be3 --- /dev/null +++ b/tests/clients/integrations-superseded-store.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { ClientPathError, type ExportModel } from "../../src/clients/config-export"; +import { previewIntegration } from "../../src/integrations/mutation-plan"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { resolveIntegrationTarget } from "../../src/integrations/target"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { readIntegrationState } from "../../src/integrations/state"; +import { + applyIntegration, + disableIntegration, + refreshIntegration, + type IntegrationWriteInput, +} from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * A write the client cannot read is refused, not reported (#5348). + * + * ZCode 3.14 moved its providers to a second file and kept the first one + * reachable only through an import that runs once, on an install that has never + * created the second. Everything the integration checks still passed: the file + * was writable, the block merged, the journal recorded a correct apply. The + * only wrong part was the report, so these cases are about what the operation + * SAYS as much as what it writes. + * + * These are the cases where the store cannot be written: it holds no document + * whose schema has been observed, or it is not there at all. Writing the store + * is the sibling file; this one is what happens when that is impossible, which + * is why the refusal has to stay correct rather than become dead code. + * + * Every path here comes from the registry resolvers rather than a literal, so a + * change to where either file lives moves the fixture with the source instead + * of leaving it green against a location nobody uses. + */ +let home: string; +let store: IntegrationStateStore; + +const TEST_ENV = {} as NodeJS.ProcessEnv; + +const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, +]; + +const CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as unknown as OcxConfig; + +beforeEach(() => { + const base = mkdtempSync(join(tmpdir(), "ocx-superseded-store-")); + home = join(base, "home"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(join(base, "store", "integrations")); +}); + +afterEach(() => { + removeTreeWithRetry(dirname(home)); +}); + +const spec = () => INTEGRATION_CLIENTS.zcode; + +/** Install the client: its detect directory and the parent of its config file. */ +function installZcode(): string { + mkdirSync(spec().detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec().configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; +} + +/** The file whose presence means the client stopped reading its config file. */ +function storePath(env: NodeJS.ProcessEnv = TEST_ENV): string { + return spec().currentStore!.path(env, home); +} + +function createStore(contents = "{}\n"): string { + const path = storePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); + return path; +} + +function input(overrides: Partial = {}): IntegrationWriteInput { + return { clientId: "zcode", models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store, ...overrides }; +} + +describe("a client that moved its provider store", () => { + test("apply refuses instead of writing a file nothing reads", () => { + const configPath = installZcode(); + const storeFile = createStore(); + + const result = applyIntegration(input()); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("superseded_store"); + // Both locations, because the user has to know which file to look at and + // which one the client opens instead. + expect(result.message).toContain(storeFile); + expect(result.message).toContain(configPath); + } + // The point of the refusal: nothing was written, and nothing claims we own + // anything here. + expect(existsSync(configPath)).toBe(false); + expect(Object.keys(store.readRecords())).not.toContain("zcode"); + }); + + test("refresh refuses on the same evidence", () => { + installZcode(); + createStore(); + const result = refreshIntegration(input()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("superseded_store"); + }); + + test("the preview refuses before the operator confirms anything", () => { + installZcode(); + createStore(); + const plan = previewIntegration(input(), { operation: "apply" }); + expect(plan.refusalReason).toBe("superseded_store"); + expect(plan.canApply).toBe(false); + expect(plan.willChange).toBe(false); + }); + + test("an absent store leaves the existing write path alone", () => { + const configPath = installZcode(); + expect(existsSync(storePath())).toBe(false); + + const result = applyIntegration(input()); + + expect(result.ok).toBe(true); + // A client that has never run still imports the file we write, which is why + // the refusal is keyed on the store's presence and not on the client at all. + expect(readFileSync(configPath, "utf8")).toContain("opencodex"); + expect(readIntegrationState(input())).toMatchObject({ state: "current" }); + }); + + test("status reports the store beside a block that really is current", () => { + installZcode(); + expect(applyIntegration(input()).ok).toBe(true); + const storeFile = createStore(); + + const status = readIntegrationState(input()); + + // Both halves are true at once, and only the second one is new. + expect(status.state).toBe("current"); + expect(status.supersededBy).toBe(storeFile); + }); + + test("disable still removes what we wrote before the client moved", () => { + const configPath = installZcode(); + expect(applyIntegration(input()).ok).toBe(true); + createStore(); + + const result = disableIntegration(input()); + + expect(result.ok).toBe(true); + // Removing our own bytes from this file is as effective as it ever was, and + // refusing it would leave the block unremovable through the tool. + expect(readFileSync(configPath, "utf8")).not.toContain("opencodex"); + expect(readIntegrationState(input())).toMatchObject({ state: "absent" }); + }); + + test("only a regular file counts as the store", () => { + installZcode(); + // A directory at that path is not a provider list the client loaded, and + // refusing over it would block an apply that would have worked. + mkdirSync(storePath(), { recursive: true }); + const resolved = resolveIntegrationTarget({ + clientId: "zcode", + configPath: spec().configPath(TEST_ENV, home), + io: store.io(), + record: null, + env: TEST_ENV, + home, + }); + expect(resolved.ineffective).toBeNull(); + expect(resolved.configPath).toBe(spec().configPath(TEST_ENV, home)); + expect(applyIntegration(input()).ok).toBe(true); + }); + + test("a relative store override is refused rather than resolved against a guess", () => { + installZcode(); + const env = { ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: "v2/provider_config.json" } as NodeJS.ProcessEnv; + // The client resolves it against its own working directory; we cannot know + // that one, and here the answer decides whether an apply is called effective. + expect(() => storePath(env)).toThrow(ClientPathError); + const result = applyIntegration(input({ env })); + expect(result.ok).toBe(false); + }); + + test("an operator who relocated the store is measured against the file they moved it to", () => { + installZcode(); + const relocated = join(home, "elsewhere", "provider_config.json"); + mkdirSync(dirname(relocated), { recursive: true }); + writeFileSync(relocated, "{}\n"); + const env = { ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: relocated } as NodeJS.ProcessEnv; + + const result = applyIntegration(input({ env })); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain(relocated); + // The default location is empty, so only the override could have produced this. + expect(existsSync(spec().currentStore!.path(TEST_ENV, home))).toBe(false); + }); +}); diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 4af951a290c..0a38274bb8a 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -5,6 +5,7 @@ import { dirname, join, relative } from "node:path"; import { MANAGED_PATH_TEMPLATES, PLAN_CHANGE_LIMIT, + PLAN_UNBOUND_FINGERPRINT, buildMutationPlan, canonicalSchemaPath, orderPlanChanges, @@ -88,6 +89,7 @@ const BASE: PlanFingerprintInput = { detectDir: "/home/example/.cline", installKind: "dir", admissionBlocked: false, + ineffectiveWrite: null, before: "{}", contribution: CONTRIBUTION, record: RECORD, @@ -180,7 +182,11 @@ describe("integration mutation plan projection", () => { describe("integration plan fingerprint", () => { test("is stable for the same inputs and carries its version", () => { expect(planFingerprint(BASE)).toBe(planFingerprint({ ...BASE })); - expect(planFingerprint(BASE).startsWith("p1:")).toBe(true); + // Derived from the one exported value that carries the version, so a bump + // cannot leave this case asserting the previous vocabulary. + const version = PLAN_UNBOUND_FINGERPRINT.split(":")[0]!; + expect(version).not.toBe(""); + expect(planFingerprint(BASE).startsWith(`${version}:`)).toBe(true); }); test("every authority input changes it", () => { @@ -196,6 +202,13 @@ describe("integration plan fingerprint", () => { { ...BASE, installKind: "missing" }, { ...BASE, installKind: "file" }, { ...BASE, admissionBlocked: true }, + // A client that creates its new provider store while a confirmation is + // outstanding changes whether the write can reach it at all, and leaves + // the file, the record and the contribution untouched while doing it. + { ...BASE, ineffectiveWrite: "unestablished-schema\u0000/home/example/.client/store.json" }, + // The same location with a different reason is a different answer: a store + // whose schema stops being one we recognise moves nothing on disk. + { ...BASE, ineffectiveWrite: "owned-config-file\u0000/home/example/.client/store.json" }, // Different bytes, and absent distinguished from empty: restoring over a missing file and // over an empty one are different operations. { ...BASE, before: "{ }" }, @@ -281,6 +294,8 @@ describe("integration mutation plan", () => { // Installation outranks a conflict the file would otherwise report. expect(buildMutationPlan({ ...conflicted, installKind: "missing" }).refusalReason).toBe("not_installed"); expect(buildMutationPlan({ ...conflicted, admissionBlocked: true }).refusalReason).toBe("non_loopback"); + expect(buildMutationPlan({ ...conflicted, ineffectiveWrite: "owned-config-file\u0000/store.json" }).refusalReason) + .toBe("superseded_store"); // And a conflict outranks the classifier's unsafe, which apply reports last. expect(buildMutationPlan(conflicted).refusalReason).toBe("conflict"); expect(buildMutationPlan({ ...PLAN_BASE, classified: { state: "unsafe", reason: "blocked-container" } }).refusalReason) @@ -296,6 +311,9 @@ describe("integration mutation plan", () => { // client is installed now, and it emits nothing admission policy could object to. expect(buildMutationPlan({ ...disable, installKind: "missing" }).canApply).toBe(true); expect(buildMutationPlan({ ...disable, admissionBlocked: true }).canApply).toBe(true); + // Removing bytes this project wrote to this file stays possible after the + // client stops reading it; refusing would strand the block forever. + expect(buildMutationPlan({ ...disable, ineffectiveWrite: "owned-config-file\u0000/store.json" }).canApply).toBe(true); expect(buildMutationPlan({ ...disable, classified: { state: "conflict", reason: "foreign-edit" } }).refusalReason) .toBe("conflict"); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8f764a5f531..c27b6771458 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -669,11 +669,13 @@ "injection-model-api.test.ts": "codex-integration", "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", + "integrations-current-store.test.ts": "clients", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", + "integrations-superseded-store.test.ts": "clients", "integrations-writer-frozen-config.test.ts": "clients", "integrations-writer.test.ts": "clients", "interactive-confirm.test.ts": "cli", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 78e0d20f2c8..eb7ee32ae56 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -7,6 +7,12 @@ import { createClineIO } from "../../src/integrations/cline-io"; import { parseClineDocument } from "../../src/integrations/cline-document"; import { parseConfig } from "../../src/integrations/config-io"; import { INTEGRATION_CLIENTS, INTEGRATION_CLIENT_IDS, type IntegrationClientId } from "../../src/integrations/registry"; +import { + MANAGED_PATH_TEMPLATES, + PLAN_UNBOUND_FINGERPRINT, + orderPlanChanges, + previewIntegration, +} from "../../src/integrations/mutation-plan"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { readIntegrationState, readPath } from "../../src/integrations/state"; import { applyIntegration, disableIntegration, restoreIntegration } from "../../src/integrations/writer"; @@ -111,6 +117,92 @@ describe("the client registries cannot drift apart", () => { expect(routedFileClients).toEqual(expected); }); + test("a plan the server produces is one the dashboard accepts", async () => { + const guiIntegrations = await import("../../gui/src/pages/integrations/integration-api"); + installClient("zcode"); + const input = { clientId: "zcode" as const, models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store }; + + /* + * The parser re-declares the plan vocabulary by hand for the same reason + * the client list above is re-declared: it cannot import the backend. So + * the token format and the refusal names are two more hand-maintained + * copies, and nothing but a real plan crossing the boundary catches one of + * them going stale. It has: a fingerprint version bump once became a + * client-side rejection of every preview, silently, because the parser + * matched the previous version as a literal. + */ + const applied = previewIntegration(input, { operation: "apply" }); + expect(guiIntegrations.parseIntegrationMutationPlan(JSON.parse(JSON.stringify(applied)))).toMatchObject({ + clientId: "zcode", + fingerprint: applied.fingerprint, + }); + + // And a refusal, so the reason vocabulary crosses too rather than only the + // shape of a plan that can apply. + const storePath = INTEGRATION_CLIENTS.zcode.currentStore!.path(TEST_ENV, home); + mkdirSync(dirname(storePath), { recursive: true }); + writeFileSync(storePath, "{}\n"); + const refused = previewIntegration(input, { operation: "apply" }); + expect(refused.canApply).toBe(false); + const parsed = guiIntegrations.parseIntegrationMutationPlan(JSON.parse(JSON.stringify(refused))); + expect(parsed.refusalReason).toBe(refused.refusalReason); + + /* + * The mutation itself, shaped as the route sends it. A reason missing from + * the parser's own set is not recognised as a refusal at all, so the user + * would see a bare server error instead of the sentence that names the file + * their client actually reads. + */ + const mutation = applyIntegration(input); + expect(mutation.ok).toBe(false); + if (mutation.ok) return; + expect(guiIntegrations.isIntegrationRefusalEnvelope({ + error: "integration mutation failed", + code: "integration_mutation_failed", + clientId: mutation.clientId, + state: mutation.state, + reason: mutation.reason, + message: mutation.message, + })).toBe(true); + }); + + test("every managed path the server can publish is one the dashboard accepts", async () => { + const guiIntegrations = await import("../../gui/src/pages/integrations/integration-api"); + /* + * The parser keeps its own set of managed schema paths, by hand, for the + * same reason it keeps its own client list. A template the server can put in + * a plan and the parser has never heard of is not a cosmetic mismatch: the + * dashboard answers `invalid_integration_preview_response` and the page + * shows nothing. Crossing every template rather than one plan is what makes + * a client that writes a second file visible here the day it is added. + */ + for (const clientId of INTEGRATION_CLIENT_IDS) { + const changes = orderPlanChanges(MANAGED_PATH_TEMPLATES[clientId].map(template => ({ + kind: "add" as const, + path: template.join("."), + }))); + expect(changes.length, clientId).toBe(MANAGED_PATH_TEMPLATES[clientId].length); + const parsed = guiIntegrations.parseIntegrationMutationPlan({ + version: 1, + clientId, + operation: "apply", + state: "absent", + foreignEdit: "none", + changes: changes.map(change => ({ ...change })), + // A plan that carries changes and could apply is bound by definition, so the + // sentinel cannot stand in for it here: the parser refuses an unbound + // fingerprint beside `canApply`, and that refusal is the contract, not the + // thing under test. The version prefix is taken from the sentinel so a + // version bump moves this fixture with it. + fingerprint: `${PLAN_UNBOUND_FINGERPRINT.split(":")[0]}:${"0".repeat(32)}`, + canApply: true, + willChange: true, + }); + expect(parsed.changes.map(change => change.path), clientId) + .toEqual(changes.map(change => change.path)); + } + }); + test("source preservation and cross-process locking are registry capabilities", () => { expect(INTEGRATION_CLIENTS.omp.sourcePreservingYaml?.path).toEqual(["providers", "opencodex"]); expect(INTEGRATION_CLIENTS.hermes.sourcePreservingYaml?.path).toEqual(["providers", "opencodex"]); From 8de2fa503f1b522b1b9c49f0cc97ed47849198bf Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 09:06:40 +0900 Subject: [PATCH 58/87] docs(devlog): close the cross-path contract unit with its landed record (#5382) --- .../260921_cross_path_contract/000_plan.md | 0 .../010_lane_boundaries.md | 0 .../260921_cross_path_contract/090_outcome.md | 43 +++++++++++++++++++ 3 files changed, 43 insertions(+) rename devlog/{_plan => _fin}/260921_cross_path_contract/000_plan.md (100%) rename devlog/{_plan => _fin}/260921_cross_path_contract/010_lane_boundaries.md (100%) create mode 100644 devlog/_fin/260921_cross_path_contract/090_outcome.md diff --git a/devlog/_plan/260921_cross_path_contract/000_plan.md b/devlog/_fin/260921_cross_path_contract/000_plan.md similarity index 100% rename from devlog/_plan/260921_cross_path_contract/000_plan.md rename to devlog/_fin/260921_cross_path_contract/000_plan.md diff --git a/devlog/_plan/260921_cross_path_contract/010_lane_boundaries.md b/devlog/_fin/260921_cross_path_contract/010_lane_boundaries.md similarity index 100% rename from devlog/_plan/260921_cross_path_contract/010_lane_boundaries.md rename to devlog/_fin/260921_cross_path_contract/010_lane_boundaries.md diff --git a/devlog/_fin/260921_cross_path_contract/090_outcome.md b/devlog/_fin/260921_cross_path_contract/090_outcome.md new file mode 100644 index 00000000000..250d3754a85 --- /dev/null +++ b/devlog/_fin/260921_cross_path_contract/090_outcome.md @@ -0,0 +1,43 @@ +# Outcome + +All eight lanes are on `dev`. The unit closes here; what each lane actually +changed is below, with the parts that were narrowed or left open named rather +than implied. + +| Lane | Landed as | What it changed | +|---|---|---| +| L1 | `556b670251` | The ambiguous-resend refusal now carries the same code and the same retry policy on the translated Chat wrapper, the native Chat route and Responses, from one shared verdict instead of three re-derivations from a status code | +| L2 | `0ab648b4e3` | A mid-conversation instruction keeps its slot through the Chat translation inbound instead of being folded into `instructions`; a leading system block is unchanged | +| L3 | `6e0e912ebd` | A recorded `foldDeveloperRoleToSystem` decides the role on the native Chat route as well; the unset default and the caller-message preservation test are untouched | +| L4 | `a6e8df4ec1` | The paginated-history transition is judged by conversation destination — enable, new conversation, resume, restore — rather than by a successful sync, and an admission-token home is still refused | +| L5 | `b20acc79d2` | The request's tool selection is enforced after a sparse-terminal repair, so a forbidden call cannot re-enter the terminal output, and ordinary text beside it survives | +| L6 | `5d3f5db84a` | The integration writes the provider store the client actually reads, with enable, refresh and disable symmetric, and reports an ineffective write instead of success when the store's schema is not one it knows | +| L7 | `34332bb785` | One developer-role policy across the contract document, the configuration reference and its translations, derived from the adapter so a changed default fails a check | +| L8 | `3a0718c81c` | One resend allowance per logical request across the composed recovery legs, with the physical send count and the possibly-executed send count observed separately | + +## What this unit did not close + +`#5348` is closed because its acceptance is on `dev`. The neighbouring retry +issues about a WebSocket failure stage and single-key 429 handling are not +resolved by L8 and stay open with their remaining scope recorded on the issues +themselves. + +## What the audit round changed + +The first reviewer pass returned blocking findings and two mistaken ones. The +mistaken pair read a stale checkout and reported L4's refusal and L8's gate as +still absent; both had landed the day before, so those lanes narrowed to the +part that was missing instead of reimplementing what was there. The real +findings — no owned-file set, L1 and L8 overlapping on the send path, L7 +depending on L2 and L3 without an order, and two acceptance sentences no job +could observe — are answered in `010_lane_boundaries.md` and were followed. + +## Friction worth remembering + +Two lanes conflicted on `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json` because each appended its own +registration line. The resolution is always the union; the file is a registry, +not a narrative. One lane's own tests failed for the two familiar reasons: a +test asserting a field name the type does not carry, and a fixture that set an +unbound fingerprint beside `canApply`, which the parser refuses by contract. +Both were fixed by deriving from the source rather than restating it. From bd217974ec0883e79301bcf68ed653ef7d1a67c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 09:35:27 +0900 Subject: [PATCH 59/87] docs(devlog): record the post-landing verification of the cross-path unit (#5390) --- .../100_verification.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 devlog/_fin/260921_cross_path_contract/100_verification.md diff --git a/devlog/_fin/260921_cross_path_contract/100_verification.md b/devlog/_fin/260921_cross_path_contract/100_verification.md new file mode 100644 index 00000000000..535cefe5026 --- /dev/null +++ b/devlog/_fin/260921_cross_path_contract/100_verification.md @@ -0,0 +1,48 @@ +# Verification on the landed tip + +The eight lanes were re-read on `dev` after they landed, from source rather +than from commit messages, to check that each acceptance is actually present. +Six hold as written. Three carry a boundary that the plan did not state, and +they are written down here because each of them is the kind of thing a later +reader would otherwise discover as a surprise. + +**The replay refusal is identical on all three surfaces.** The translated Chat +wrapper, the native Chat route and the Responses error path each preserve +`upstream_reset_replay_refused`, drop `Retry-After`, and send +`x-should-retry: false`. That last header is what makes the intent legible to a +client whose default is to retry a 429. + +**The Chat inbound keeps a mid-conversation instruction in place, with one +ordering rule.** Leading system text still becomes `instructions`. A developer +message that arrives inside an open tool-call batch is held until the batch +closes rather than being spliced between a call and its result, because a +transcript that interleaves them is not one any destination accepts. Outside a +batch the message stays exactly where it arrived. + +**The native Chat route honours an explicit setting only.** `true` rewrites the +developer role to system; `false` and unset return the caller's messages +untouched, which keeps the passthrough contract that route exists for. + +**The repair enforces the request's tool selection on what it reconstructs.** +The rebuilt terminal output excludes a forbidden call and keeps the ordinary +text that arrived beside it. Raw `output_item.done` blocks are not rewritten by +the repair — policing the raw stream belongs to the undeclared-tool guard, and +splitting it that way keeps one owner per question. + +**The client store is shared by enable, refresh and disable.** An unknown schema +is reported rather than merged into. When this project's own block is still in +the legacy file, refresh refuses and disable cleans that file first: the +alternative is a block left in one file while another is written, which is the +state that made the original report hard to diagnose. + +**The resend grant is one shared ledger entry.** Derived and combo budgets +inherit it rather than opening their own, so composing recovery legs cannot +multiply the allowance. The ceiling itself stays operator-configurable; what is +fixed is that there is one of it per logical request. + +## Issue dispositions + +`#5348` is closed: its acceptance is on `dev`. `#4191` and `#5180` stay open +with their remaining scope recorded on the issues — a dead mid-turn transport +still has no fallback, and a provider 429 on a single key still has no cooldown +policy. Neither is what a per-request resend budget decides. From 2fb2dfb947a9aa669e6a1a7c7974109f407f509e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 09:48:57 +0900 Subject: [PATCH 60/87] Make background-service runtime ownership durable, and stop every path that would undo it (#5386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(service): make runtime ownership a durable, preserved field Service install state gains an owner identity, an install id and a consent generation, written through a compare-and-swap that re-reads the anchor record before it commits and verifies the committed bytes afterwards. Every install, repair, update and stop path reaches the record through writeServiceInstallState, which now merges install provenance onto the existing record instead of rebuilding it, so a concurrent repair cannot drop an ownership claim the user consented to. recordServiceOwner is idempotent on the same owner and install id, so a relaunch does not inflate the consent generation; ownershipGrantedTo is the comparison rule an app-local install id is checked against. The moved fail-loud write-path resolution keeps its assertion in tests/service/launchd-repair.test.ts, now pointed at the swap. * fix(service): repair stops instead of reactivating a foreign-owned runtime ocx service repair and ocx service restart refuse before they assert, write, stop or start anything when the recorded owner is not this CLI, and name the registration as untouched. The registration is kept by decision, so the marker is the only thing that makes a takeover survive a repair run from a tray helper or from ocx update. ocx service install is the deliberate verb, so it is the one that takes ownership back: it releases the recorded marker and says whose it was. * fix(update): leave a desktop-owned runtime alone ocx update stops the running proxy and then re-registers and restarts the background service. Under a desktop owner both halves are wrong: the running server is the app's bundled sidecar, which the update cannot bring back, and the refresh re-enables the npm launcher the takeover superseded. The rule is plain ESM in src/update/runtime-ownership.mjs because both updaters need it — the Bun path in src/update/index.ts and the npm and pnpm path in bin/ocx.mjs — and the two lanes deciding this separately is how a fix ships on one side only. * test(service): cover durable ownership, the swap and the two refusals Each case was driven red once against the defect it guards: removing the ownership preserve from writeServiceInstallState fails three cases in service-ownership-state; removing the revision re-read fails the two compare-and-swap cases; stubbing the repair gate false fails both refusal cases; stubbing the update veto false fails two runtime-ownership cases. The launcher's plain-ESM ownership reader and the authoritative TypeScript one are driven through one shared table of accepted and rejected claims so they cannot drift. * docs(structure): record the runtime-ownership contract and its comparison rule Publishes the recorded claim's shape, the compare-and-swap and what it is not, the idempotent grant, and ownershipGrantedTo as the rule an installation applies to its own locally stored install id. * fix(service): read and write the ownership claim fail-closed Review of the first round found three ways the claim could be lost or misread. An unreadable path, a corrupt anchor record or state paths naming different owners all collapsed into readServiceInstallState()'s single null, which every caller read as "the CLI owns it" — so a permissions error reactivated the npm launcher over a consented takeover. resolveServiceOwnership answers none, owned or unknown across every state path, and absence is now the only thing that means no claim. The compare-and-swap was not atomic: two processes could both pass the revision check, both commit and both verify their own bytes, after which the second silently dropped the first's mutation and reported success. An O_EXCL lock beside the anchor now covers the whole read-modify-write, re-entrant within a process; the revision check stays for writers that do not take it. The write also preserves a claim that exists only on the legacy mirror. consentGenerationCeiling survives a release, so grant, release, grant no longer produces generation 1 twice — an app-local record still holding the first 1 would have read the second grant as its own prior consent. Separately, parseServiceInstallState rejected cliPath: null, which cliEntry() returns for a standalone binary. Every standalone install therefore wrote a record its own parser refused. Pre-existing, and load-bearing now that ownership lives in that record. * fix(service): gate every verb that activates the npm registration repair and restart now refuse an unknown owner as well as a foreign one. ocx service start joins them: the Windows tray starts the service automatically, which would have put a second proxy beside the one the desktop app is running. It reports rather than throws, because the tray drives it through a caller that does not catch. stop and uninstall stay ungated — they deactivate. install releases the marker only after the registration succeeded. Releasing first meant a cancelled UAC prompt or a failed cleanup left the retained npm registration looking CLI-owned, so the next incidental repair would reactivate it. * fix(update): apply the veto in all three update lanes, and re-read it The dashboard worker in src/update/job.ts was a third lane with no ownership check. It defaults to restarting, so after the package updater correctly left a desktop-owned runtime alone, the sidecar's unchanged pid failed its restart evidence and it reclaimed the port, ran the repair that now refuses, and fell through to a direct npm start. Both package updaters re-read the claim before any direct-start fallback: ownership is sampled before an install that can take minutes, and the post-update repair's refusal is indistinguishable from a generic failure at that layer. An unreadable record now vetoes instead of granting the ordinary path. The worker's gate lives in src/update/restart-ownership.ts because src/update/job.ts is at the repository size threshold; extracting keeps it under without moving a cap. * test: cover the fail-closed reader, the lock, the ceiling and the third update lane Driven red once each: disabling the unreadable and invalid branches of resolveServiceOwnership fails two cases; opening the lock file without O_EXCL fails the lock case; dropping the generation ceiling fails two cases; restoring the cliPath: null rejection fails the standalone-record case; and removing the dashboard worker's veto fails its gate case. Two existing source-oracle assertions were adjusted to the new shape while keeping what they encoded: service-start-environment now proves the environment guard still precedes ops.start() with nothing that reaches the service manager in between, and launchd-repair follows its slice anchor. * docs(structure): record the lock, the fail-closed read and which verbs are gated * test(service): restore every service-state path the ownership fixture writes A write lands on the legacy ~/.opencodex/service-state.json entry as well as the pinned home, and under the suite that second path is the shared sandbox home, which outlives one test file. A claim recorded here reappeared in tests/service/service.test.ts, where the repair gate refused thirty scheduler cases, and in the dashboard worker's tests, where the restart veto fired on state they never wrote. --- bin/ocx.mjs | 42 +- scripts/test-layout/layout.json | 3 + src/service.ts | 6 +- src/service/cli.ts | 41 +- src/service/repair.ts | 46 +- src/service/state.ts | 483 +++++++++++++++++- src/update/index.ts | 46 +- src/update/job.ts | 7 + src/update/restart-ownership.ts | 32 ++ src/update/runtime-ownership.d.mts | 16 + src/update/runtime-ownership.mjs | 97 ++++ structure/runtime.md | 45 ++ tests/fixtures/test-layout-expected.json | 3 + tests/service/launchd-repair.test.ts | 5 +- .../service-ownership-handover.test.ts | 147 ++++++ tests/service/service-ownership-state.test.ts | 334 ++++++++++++ .../service/service-start-environment.test.ts | 11 +- tests/update/update-desktop-owner.test.ts | 173 +++++++ tests/update/update-stop-first.test.ts | 8 +- 19 files changed, 1512 insertions(+), 33 deletions(-) create mode 100644 src/update/restart-ownership.ts create mode 100644 src/update/runtime-ownership.d.mts create mode 100644 src/update/runtime-ownership.mjs create mode 100644 tests/service/service-ownership-handover.test.ts create mode 100644 tests/service/service-ownership-state.test.ts create mode 100644 tests/update/update-desktop-owner.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 7b3a54eaa2c..f7cce9523df 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -12,6 +12,7 @@ import { spawn, spawnSync } from "node:child_process"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; +import { parseRecordedOwnership, planUpdateRuntimeHandling } from "../src/update/runtime-ownership.mjs"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -258,6 +259,28 @@ function runPackageManagerSelfUpdate(manager) { // unloads it, so a successful update must refresh and restart it afterwards. const serviceStatePath = join(configDir(), "service-state.json"); const serviceWasInstalled = existsSync(serviceStatePath); + // What this update may do to the runtime. The same rule the Bun updater applies, from the + // same module: a desktop takeover vetoes both the stop and the service refresh below. + const readOwnership = () => { + if (!existsSync(serviceStatePath)) return { ownership: null, ownershipUnknown: false }; + try { + const raw = readFileSync(serviceStatePath, "utf8"); + // Fails CLOSED on a record that exists but does not parse: unreadable and malformed + // are not "nobody owns it", and reading them that way reactivates the npm launcher + // over a takeover the user consented to. + if (!raw.trim()) return { ownership: null, ownershipUnknown: true }; + let parsed; + try { parsed = JSON.parse(raw); } catch { return { ownership: null, ownershipUnknown: true }; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ownership: null, ownershipUnknown: true }; + if (parsed.ownership === undefined) return { ownership: null, ownershipUnknown: false }; + const ownership = parseRecordedOwnership(raw); + return ownership ? { ownership, ownershipUnknown: false } : { ownership: null, ownershipUnknown: true }; + } catch { + return { ownership: null, ownershipUnknown: true }; + } + }; + const runtimePlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + if (runtimePlan.notice) console.log(runtimePlan.notice); const trayBeforeUpdate = planWindowsTrayUpdate( process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, ); @@ -421,6 +444,14 @@ function runPackageManagerSelfUpdate(manager) { } } if (needDirectStart) { + // Re-read rather than reuse the plan from before the package install: the app can + // claim the runtime during an update that takes minutes, and the refusal that repair + // just returned is indistinguishable from any other failure at this layer. + const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true }); + if (!nowOwned.stopRuntime) { + console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); + return; + } // 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 @@ -462,11 +493,14 @@ function runPackageManagerSelfUpdate(manager) { existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); function recoverStoppedRuntimeAfterFailure() { + // Nothing was stopped under a foreign owner, so there is nothing to recover — and + // starting a proxy here would put a second one beside the runtime the app is managing. + if (!runtimePlan.stopRuntime) return; if (!postUpdateLauncherUsable) { console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); return; } - if (serviceWasInstalled) { + if (runtimePlan.refreshService) { console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service."); refreshBackgroundServiceOrStartDirect(); } else if (hasRuntimeState) { @@ -482,7 +516,7 @@ function runPackageManagerSelfUpdate(manager) { // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides // whether the obligation is safe to finish. const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); - if (serviceWasInstalled || hasRuntimeState || hasPendingTeardown) { + if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)) { console.log("⏹ Stopping the running proxy before updating..."); const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true }); const stillHasRuntimeState = @@ -637,10 +671,10 @@ function runPackageManagerSelfUpdate(manager) { } // The stop above unloaded any managed service; refresh via the freshly-installed // launcher so the new files write the baked paths and the service restarts. - if (serviceWasInstalled) { + if (runtimePlan.refreshService) { console.log("Refreshing the background service with the updated files..."); refreshBackgroundServiceOrStartDirect(); - } else { + } else if (runtimePlan.stopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } process.exit(0); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6b8712e526f..ac55bc77987 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1368,6 +1368,8 @@ "server-xai-header-parity.test.ts": "server", "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", + "service-ownership-handover.test.ts": "service", + "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", "service-secrets.test.ts": "service", "service-sqlite-home.test.ts": "service", @@ -1471,6 +1473,7 @@ "umans-provider.test.ts": "providers", "uninstall.test.ts": "cli", "update-badge.test.ts": "update", + "update-desktop-owner.test.ts": "update", "update-job.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", diff --git a/src/service.ts b/src/service.ts index 149b1ae02c7..ebeca0258cd 100644 --- a/src/service.ts +++ b/src/service.ts @@ -6,8 +6,8 @@ * restore it via the command. */ -export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence } from "./service/state"; -export { SERVICE_MANAGED_ENV, stableLauncherEntry, serviceLogPath, serviceStatePathsForOpenCodexHome, parseServiceInstallState, inspectServiceStateEvidence, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs } from "./service/state"; +export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceOwner, ServiceOwnership, ServiceOwnershipResolution, ServiceStateSwapDeps } from "./service/state"; +export { SERVICE_MANAGED_ENV, stableLauncherEntry, serviceLogPath, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, swapServiceInstallState, serviceOwnership, resolveServiceOwnership, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; export type { ServiceApiTokenOrigin, ProvisionedServiceApiToken } from "./service/guards"; export { ServiceOwnershipError, isServiceOwnershipError, serviceEnvironmentOwnedHere, assertServiceEnvironmentMatchesInstall, serviceRetryCommand, assertNotAdminToken, assertServiceAuthEnvironment, writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./service/guards"; export { resolveServiceListenPort, installedServiceListenPort, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, confirmServiceServing, reportServiceServing, resolvedProxyEnv } from "./service/health"; @@ -21,7 +21,7 @@ export { buildWindowsServiceScript, buildWindowsSchtasksCreateArgs, buildWindows export type { WindowsSchedulerRegistrationStageDeps, FreshWindowsSchedulerRegistrationDeps, RemoveNativeWindowsServiceDeps } from "./service/windows-ops"; export { windowsListenPort, winswListenPort, writeServiceDefinitionFile, definitionCarriesCredential, stageWindowsSchedulerRegistrationXml, stageElevatedSchedulerRegistration, describeElevatedRegistrationFailure, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, assertWindowsNativeServiceAccountSupported, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, classifyWindowsServiceStop } from "./service/windows-ops"; export type { ServiceRepairVerb, RepairServiceDeps } from "./service/repair"; -export { repairService } from "./service/repair"; +export { repairService, foreignServiceOwnerRefusal, unknownServiceOwnerRefusal } from "./service/repair"; export type { ServiceInstallPreparationDeps, FreshWindowsSchedulerInstallDeps, ServiceStopOutcome, ServiceUninstallOutcome } from "./service/orchestration"; export { proxyStillLiveAfterStop, prepareServiceInstall, installServiceSafely, installFreshWindowsSchedulerSafely, installedServiceRespawnRisk, stopServiceIfInstalledDetailed, setUninstallServiceHooksForTests, uninstallServiceDetailed, uninstallServiceIfInstalled, isServiceInstalled, isServiceViable } from "./service/orchestration"; export type { ServiceDiagnostic, WindowsTaskDiagnosticIdentityDeps, WindowsServiceDiagnosticInputs, LaunchdServiceDiagnosticInputs } from "./service/diagnostics"; diff --git a/src/service/cli.ts b/src/service/cli.ts index 86f99a0be32..2dbce4599d3 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -13,7 +13,8 @@ import { resolveServiceListenPort, reportServiceServing } from "./health"; import { platformOps, proxyStillLiveAfterStop, stopTrackedProxyForServiceCommand, installServiceSafely, installFreshWindowsSchedulerSafely, removeServiceInstallState, isServiceInstalled } from "./orchestration"; import { repairService } from "./repair"; import type { ServiceRepairVerb } from "./repair"; -import { TASK, plistPath, readServiceBackend } from "./state"; +import { TASK, plistPath, readServiceBackend, releaseServiceOwner, resolveServiceOwnership } from "./state"; +import { foreignServiceOwnerRefusal, unknownServiceOwnerRefusal } from "./repair"; import type { ServiceBackend } from "./state"; import { unitPath } from "./systemd"; import { inspectWindowsSchedulerServiceStatus, schtasksErrorDetail, probeWindowsSchedulerTask } from "./windows-scheduler"; @@ -258,6 +259,23 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise void; /** Restarts a launchd job the install path deliberately left alone. `restart` only. */ restartLaunchd?: () => void; + /** Resolves the recorded runtime owner across every state path. */ + readOwnership?: () => ServiceOwnershipResolution; /** * Which CLI verb is being served. `repair` must leave a healthy service alone — that no-op * IS the #4236 fix — while `restart` promises a new process, so on darwin it kicks the job @@ -61,6 +64,37 @@ export interface RepairServiceDeps { platform?: NodeJS.Platform; } +/** + * Why a repair stops when something other than this CLI owns the runtime. + * + * The npm service registration is KEPT — the maintainer's decision is that a user's install + * is never deleted — so the recorded owner is the only thing standing between a takeover the + * user consented to and the next `ocx service repair`, which would re-enable and restart the + * npm launcher without mentioning it. Repair runs incidentally: a tray helper, `ocx update`, + * a doctor suggestion. `install` is the deliberate act, so `install` is the verb that takes + * ownership back. + */ +export function foreignServiceOwnerRefusal(ownership: ServiceOwnership, action = "repair"): string { + return `Background service ${action} stopped: the desktop app owns the runtime ` + + `(install ${ownership.installId}, consent generation ${ownership.consentGeneration}).\n` + + "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n" + + "Quit the desktop app and run 'ocx service install' to hand the runtime back to this CLI."; +} + +/** + * Why an unreadable or contradictory record stops a repair too. + * + * Collapsing "I could not read the claim" into "there is no claim" is how a consented + * takeover gets reactivated by a permissions error. `ocx service install` is deliberately + * not gated, so this never leaves an operator without a way forward. + */ +export function unknownServiceOwnerRefusal(reason: string, action = "repair"): string { + return `Background service ${action} stopped: ${reason}, so the runtime's recorded owner could ` + + "not be determined.\n" + + "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n" + + "Run 'ocx service install' to re-register the service and take the runtime back."; +} + async function assertSchedulerSnapshotBeforeStart( readSchedulerXml: () => string, expectedXml: string, @@ -128,6 +162,16 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise throw new Error("Background service is not installed. Run 'ocx service install' first."); } + // Before anything is asserted, written, stopped or started. A repair that has already + // rewritten the assets has changed the thing it was supposed to leave alone. + const ownership = (deps.readOwnership ?? resolveServiceOwnership)(); + if (ownership.kind === "unknown") throw new Error(unknownServiceOwnerRefusal(ownership.reason)); + // `!== "cli"` rather than `=== "desktop"`: this is the CLI's own repair path, so any owner + // that is not this CLI is one whose runtime it must not touch. + if (ownership.kind === "owned" && ownership.ownership.owner !== "cli") { + throw new Error(foreignServiceOwnerRefusal(ownership.ownership)); + } + (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)(); (deps.assertAuth ?? assertServiceAuthEnvironment)(); diff --git a/src/service/state.ts b/src/service/state.ts index 2de6d6ea1f8..65b7bd85616 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -1,4 +1,4 @@ -import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { accessSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; @@ -241,6 +241,79 @@ export interface ServiceInstallState { backend?: ServiceBackend; winswVersion?: string; winswSha256?: string; + /** + * Bumped by every write through {@link swapServiceInstallState}; the compare-and-swap + * token. Absent means a record written before this field existed, which compares equal + * to 0 so the first swap over it still lands. + */ + revision?: number; + /** Who owns the running proxy. Absent means the CLI install that registered the service. */ + ownership?: ServiceOwnership; + /** + * The highest consent generation this record has ever carried, kept across a release. + * + * Without it the counter is an ABA token: granting, releasing and granting again produces + * generation 1 twice, and an app-local record holding the first 1 would read the second + * one as its own prior consent. + */ + consentGenerationCeiling?: number; +} + +/** + * The two kinds of installation that can own the proxy. + * + * `cli` is the npm (or standalone) `ocx` install that registered the background service. + * `desktop` is the packaged app, which brings its own bundled runtime. + */ +export type ServiceOwner = "cli" | "desktop"; + +/** + * Durable ownership, recorded in the shared service install state. + * + * Ownership used to be a boolean the desktop shell recomputed at every launch from whether + * it happened to spawn a child, so a restart silently demoted the app back to guest and + * "ask once, then own permanently" could not be expressed at all. This record is the thing + * that survives the restart. + * + * ABSENT IS NOT UNOWNED. Every installation that predates this field has no record, and the + * npm service registration is what owns the runtime there, so absence has to keep meaning + * exactly that. + */ +export interface ServiceOwnership { + readonly owner: ServiceOwner; + /** + * Opaque identity of the owning INSTALLATION — not of the user, the machine or the + * account. The desktop app keeps the same value in its own app-local store, and comparing + * the two through {@link ownershipGrantedTo} is how a reinstalled app tells its own prior + * consent from another installation's. + */ + readonly installId: string; + /** + * Increments once per ownership grant. Re-recording the same owner and install id leaves + * it alone, so a relaunch cannot inflate it and "exactly one increment per takeover" is + * an assertion a test can make. + */ + readonly consentGeneration: number; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +/** + * Validate an ownership claim read off disk. + * + * Returns the ORIGINAL object rather than a rebuilt one: a newer writer may carry fields + * this version does not know about, and rebuilding would drop them on the next preserve — + * which is the same lost-field failure this whole record exists to stop. + */ +export function parseServiceOwnership(value: unknown): ServiceOwnership | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const ownership = value as Record; + if (ownership.owner !== "cli" && ownership.owner !== "desktop") return null; + if (typeof ownership.installId !== "string" || ownership.installId.length === 0) return null; + if (!isNonNegativeInteger(ownership.consentGeneration)) return null; + return value as ServiceOwnership; } export function parseServiceInstallState(value: unknown): ServiceInstallState | null { @@ -249,9 +322,22 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState | if (state.version !== 1 && state.version !== 2) return null; if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null; if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null; - for (const key of ["codexSqliteHome", "bunPath", "cliPath", "launcherPath", "winswVersion", "winswSha256"] as const) { + for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"] as const) { if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null; } + // `cliPath` is the one path that is legitimately null: `cliEntry()` returns null for a + // standalone binary, and the writer stores it. Validating it as a non-empty string made + // every standalone install write a record its OWN parser rejected — which reads back as + // "no install state", and after ownership moved into this record, as "nobody owns the + // runtime". Pre-existing; found while making ownership fail closed. + if (state.cliPath !== undefined && state.cliPath !== null + && (typeof state.cliPath !== "string" || state.cliPath.length === 0)) return null; + if (state.revision !== undefined && !isNonNegativeInteger(state.revision)) return null; + if (state.consentGenerationCeiling !== undefined && !isNonNegativeInteger(state.consentGenerationCeiling)) return null; + // A malformed ownership claim invalidates the whole record instead of being dropped: + // silently discarding it is precisely the demotion this field is here to prevent, and a + // reader that cannot trust the claim must not be told the runtime is unowned. + if (state.ownership !== undefined && parseServiceOwnership(state.ownership) === null) return null; if (state.version === 1) { if (state.backend !== undefined) return null; } else if (state.backend !== "scheduler" && state.backend !== "native") { @@ -260,10 +346,17 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState | return state as unknown as ServiceInstallState; } -export function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void { +/** + * What an install bakes into the record: the homes, the provenance paths and the backend. + * + * Everything here is rebuilt from the CURRENT process on every write, which is the point — + * it describes the install that just ran. {@link ServiceInstallState.ownership} deliberately + * is not part of it. + */ +function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string | null): ServiceInstallState { const { bun, cli } = cliEntry(); const codexHome = currentCodexHome(); - const state: ServiceInstallState = { + return { version: 2, codexHome, opencodexHome: currentOpenCodexHome(), @@ -274,14 +367,44 @@ export function writeServiceInstallState(backend: ServiceBackend = "scheduler", backend, ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}), }; - for (const path of serviceStateWritePaths()) { - const dir = dirname(path); - recordOwnedConfigPath(getConfigDir(), path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: true }); - } +} + +/** + * Record an install, PRESERVING whatever owns the runtime. + * + * Every install, repair, update and stop path ends here, and each one used to hand this + * function a freshly rebuilt record that simply replaced the file. That is why ownership + * cannot be an ordinary field written by whoever ran last: a repair kicked off by a tray + * helper, or by `ocx update`, would erase a takeover the user had consented to and hand the + * runtime back to the npm launcher without saying anything. Preserving it here is what makes + * the consent durable. + */ +export function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void { + // Resolved across every state path, so a claim living only on the legacy mirror is carried + // onto the anchor rather than lost the first time this home writes. + // + // This does NOT refuse on an unknown resolution. It runs at the END of a successful install + // or repair, where a throw would report a service that is registered and running as a + // failure. The fail-closed decision belongs in front of the mutation, where repair and the + // updaters make it; here the job is to preserve as much as can be read. + const resolution = resolveServiceOwnership(); + swapServiceInstallState(current => ({ + ...installProvenanceRecord(backend, launcherPath), + ...preservedConsent(current, resolution), + })); +} + +/** The ownership half of a record: the claim itself plus the generation high-water mark. */ +function preservedConsent( + current: ServiceInstallState | null, + resolution: ServiceOwnershipResolution, +): Pick { + const ownership = resolution.kind === "owned" ? resolution.ownership : current?.ownership; + const ceiling = Math.max(current?.consentGenerationCeiling ?? 0, ownership?.consentGeneration ?? 0); + return { + ...(ownership ? { ownership } : {}), + ...(ceiling > 0 ? { consentGenerationCeiling: ceiling } : {}), + }; } export function readServiceInstallState(): ServiceInstallState | null { @@ -296,6 +419,342 @@ export function readServiceInstallState(): ServiceInstallState | null { return null; } +/** Raised when a state write kept losing its compare-and-swap; NOTHING was written. */ +export class ServiceStateConflictError extends Error { + constructor(readonly path: string, readonly attempts: number) { + super( + `service install state at ${path} was rewritten by another process during all ${attempts} ` + + "compare-and-swap attempts; nothing was written. Re-run the command.", + ); + this.name = "ServiceStateConflictError"; + } +} + +export interface ServiceStateSwapDeps { + /** Test seam: which state paths to write. Defaults to every writable state path. */ + paths?: readonly string[]; + /** How many times to re-read and recompute before giving up. */ + attempts?: number; + /** + * Test seam: runs immediately before each commit. It is the only place a competing writer + * can be interleaved deterministically, which is what makes the revision check testable + * rather than a claim in a comment. + */ + beforeCommit?: (attempt: number) => void; + /** How long to wait for another process to release the anchor lock. */ + lockWaitMs?: number; +} + +const SERVICE_STATE_SWAP_ATTEMPTS = 5; +const SERVICE_STATE_LOCK_WAIT_MS = 2_000; +const SERVICE_STATE_LOCK_POLL_MS = 20; +const SERVICE_STATE_LOCK_STALE_MS = 30_000; +/** Lock paths this process holds, with a depth so a nested swap does not deadlock on itself. */ +const heldStateLocks = new Map(); + +function isFileExistsError(error: unknown): boolean { + return Boolean(error) && typeof error === "object" && "code" in (error as object) + && (error as { code?: unknown }).code === "EEXIST"; +} + +/** + * Hold an exclusive lock over the anchor record for one whole read-modify-write. + * + * The revision check alone cannot make the swap atomic: two processes can both pass it, + * both commit, and both verify their own bytes, after which the second silently drops the + * first's mutation and reports success. `O_EXCL` creation is the cheap cross-process + * exclusion that closes it for every writer that comes through here. + * + * The revision check stays anyway, because this lock binds only cooperating writers — an + * older `ocx` on the same machine does not take it. + * + * Re-entrant per process. A swap nested inside another one is a caller ordering its own + * writes, not a race, and blocking it would be a self-deadlock. + */ +function withServiceStateLock(anchor: string, run: () => T, waitMs = SERVICE_STATE_LOCK_WAIT_MS): T { + const lockPath = `${anchor}.lock`; + const depth = heldStateLocks.get(lockPath); + if (depth !== undefined) { + heldStateLocks.set(lockPath, depth + 1); + try { return run(); } finally { releaseHeldLock(lockPath, false); } + } + const dir = dirname(lockPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const deadline = Date.now() + waitMs; + let fd: number; + for (;;) { + try { + fd = openSync(lockPath, "wx", 0o600); + break; + } catch (error) { + // A lock we could not create for any reason OTHER than "it is held" is a filesystem + // failure, and writing the record anyway is the unprotected path this exists to close. + if (!isFileExistsError(error)) throw error; + if (Date.now() < deadline) { Bun.sleepSync(SERVICE_STATE_LOCK_POLL_MS); continue; } + // Break a lock whose holder is gone. Age comes from the lock file itself, so a holder + // that is merely slow keeps refusing us rather than being evicted mid-write. + let ageMs: number | null = null; + try { ageMs = Date.now() - statSync(lockPath).mtimeMs; } catch { ageMs = null; } + if (ageMs !== null && ageMs > SERVICE_STATE_LOCK_STALE_MS) { + try { unlinkSync(lockPath); } catch { /* another process broke it first */ } + continue; + } + throw new Error( + `another process is writing the service install state at ${anchor} and did not release ` + + `it within ${waitMs}ms; nothing was written. Re-run the command.`, + ); + } + } + heldStateLocks.set(lockPath, 1); + try { + return run(); + } finally { + try { closeSync(fd); } catch { /* best-effort */ } + releaseHeldLock(lockPath, true); + } +} + +function releaseHeldLock(lockPath: string, removeFile: boolean): void { + const depth = (heldStateLocks.get(lockPath) ?? 1) - 1; + if (depth > 0) { heldStateLocks.set(lockPath, depth); return; } + heldStateLocks.delete(lockPath); + if (removeFile) { try { unlinkSync(lockPath); } catch { /* best-effort */ } } +} + +/** One state path's record, or null when it is absent or unparseable. Throws if unreadable. */ +function readServiceInstallStateAt(path: string): ServiceInstallState | null { + const evidence = inspectServiceStateEvidence([path])[0]!; + // Unreadable is not absent. Treating EACCES as "no record" would compute a swap from an + // empty base and erase an ownership claim we were merely not allowed to look at. + if (evidence.kind === "unreadable") { + throw new Error( + `service install state at ${path} could not be read (${evidence.reason}), so its recorded ` + + "owner cannot be preserved; nothing was written. Fix the file's permissions and retry.", + ); + } + // Invalid IS overwritten: there is no claim in an unparseable record to preserve. + return evidence.kind === "valid" ? evidence.state : null; +} + +function commitServiceStateFile(path: string, serialized: string): void { + const dir = dirname(path); + recordOwnedConfigPath(getConfigDir(), path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(path, serialized, { encoding: "utf8", mode: 0o600 }); + try { chmodSync(path, 0o600); } catch { /* best-effort */ } + if (process.platform === "win32") hardenSecretPath(path, { required: true }); +} + +/** + * Read the recorded state, compute the next one from it, and commit it only if nothing else + * moved the record in between. + * + * `mutate` may return null to mean "nothing to change", which writes nothing and leaves the + * file — including its absence — exactly as it was. + * + * WHAT THE REVISION CHECK IS. The anchor is re-read immediately before the commit and the + * committed bytes are read back immediately after, so a writer that landed on either side of + * the window is DETECTED and the whole read-modify-write runs again against the new base. + * The comparison is over the serialized record rather than the revision number alone, + * because two writers racing from one base both compute the same next revision — identical + * bytes mean nothing was lost, and differing bytes mean something was. + * + * WHAT THE LOCK IS. {@link withServiceStateLock} holds the anchor exclusively for the whole + * read-modify-write, because the revision check alone is not atomic: two processes can both + * pass it, both commit and both verify their own bytes, after which the second silently + * drops the first's mutation and reports success. The revision check remains the guard + * against a writer that does not take the lock, such as an older `ocx` on the same machine. + */ +export function swapServiceInstallState( + mutate: (current: ServiceInstallState | null) => ServiceInstallState | null, + deps: ServiceStateSwapDeps = {}, +): ServiceInstallState | null { + const paths = deps.paths ?? serviceStateWritePaths(); + // The anchor is the first path, which is the state path for THIS OpenCodex home; + // `readServiceInstallState` reads the same list in the same order, so the record the + // swap compares against is the record every reader resolves. The remaining paths are + // legacy mirrors and receive a copy of whatever the anchor commits. + const anchor = paths[0]; + if (anchor === undefined) throw new Error("refusing to swap service install state with no state path"); + const attempts = deps.attempts ?? SERVICE_STATE_SWAP_ATTEMPTS; + return withServiceStateLock(anchor, () => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const base = readServiceInstallStateAt(anchor); + const baseRevision = base?.revision ?? 0; + const candidate = mutate(base); + if (candidate === null) return base; + const next: ServiceInstallState = { ...candidate, revision: baseRevision + 1 }; + const serialized = JSON.stringify(next, null, 2) + "\n"; + deps.beforeCommit?.(attempt); + if ((readServiceInstallStateAt(anchor)?.revision ?? 0) !== baseRevision) continue; + for (const path of paths) commitServiceStateFile(path, serialized); + let committed: string | null = null; + try { committed = readFileSync(anchor, "utf8"); } catch { /* the comparison below decides */ } + if (committed === serialized) return next; + } + throw new ServiceStateConflictError(anchor, attempts); + }, deps.lockWaitMs); +} + +/** The recorded owner of ONE already-read record, or null. Prefer {@link resolveServiceOwnership}. */ +export function serviceOwnership(state: ServiceInstallState | null = readServiceInstallState()): ServiceOwnership | null { + return state?.ownership ?? null; +} + +/** + * What every state path, together, says about who owns the runtime. + * + * An unknown resolution is the answer that matters. \`readServiceInstallState\` collapses + * unreadable, malformed and absent into one null, and a caller that reads that null as "the + * CLI owns it" will re-enable the npm launcher over a claim it merely failed to read — the + * exact demotion the record exists to prevent. Absence is the only thing that may mean no + * claim. + */ +export type ServiceOwnershipResolution = + | { readonly kind: "none" } + | { readonly kind: "owned"; readonly ownership: ServiceOwnership } + | { readonly kind: "unknown"; readonly reason: string }; + +export function resolveServiceOwnership( + evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(), +): ServiceOwnershipResolution { + for (const entry of evidence) { + // Any path. A claim we are not allowed to look at is still a claim. + if (entry.kind === "unreadable") { + return { kind: "unknown", reason: `a service state path could not be read (${entry.reason})` }; + } + } + // Only the ANCHOR's corruption is fatal. The second path is the legacy default-home entry + // kept so an install made before OPENCODEX_HOME existed can still be found; unrelated junk + // left there by an old version must not be able to block every repair on this machine. + if (evidence[0]?.kind === "invalid") { + return { kind: "unknown", reason: "the service install record is present but not valid" }; + } + const claims = evidence.flatMap(entry => ( + entry.kind === "valid" && entry.state.ownership ? [entry.state.ownership] : [] + )); + const first = claims[0]; + if (first === undefined) return { kind: "none" }; + if (claims.some(claim => claim.owner !== first.owner || claim.installId !== first.installId)) { + return { kind: "unknown", reason: "the service state paths name different owners" }; + } + // Same claim in both places; the higher generation is the later write. + return { + kind: "owned", + ownership: claims.reduce((best, claim) => claim.consentGeneration > best.consentGeneration ? claim : best, first), + }; +} + +/** + * Whether the packaged desktop app owns the runtime. + * + * This is the predicate `ocx service repair` and `ocx update` consult before they would + * re-enable, rewrite or restart the npm service registration. The registration itself is + * kept either way — the maintainer's decision is that the user's install is never deleted, + * so this marker is the only thing that makes the takeover durable. + */ +export function desktopOwnsService(state: ServiceInstallState | null = readServiceInstallState()): boolean { + return serviceOwnership(state)?.owner === "desktop"; +} + +/** + * THE COMPARISON RULE. An installation holds the recorded grant only when both the kind of + * owner and the install id match its own. + * + * The desktop app calls this at launch with the install id from its app-local store. True + * means this very installation already has consent and must not ask again. False with a + * non-null `ownership` means a DIFFERENT installation owns the runtime — a reinstalled app, + * or a second copy — and consent has to be asked before taking over. Null means nothing is + * recorded and the npm install still owns it. + */ +export function ownershipGrantedTo( + ownership: ServiceOwnership | null, + owner: ServiceOwner, + installId: string, +): boolean { + return ownership !== null && ownership.owner === owner && ownership.installId === installId; +} + +/** + * The record an ownership write lands on when no install state exists yet. + * + * Deliberately carries no `bunPath`, `cliPath` or `launcherPath`: those are baked BY AN + * INSTALL, and a takeover is not one. Recording the claiming process's own paths as install + * provenance would make `ocx service status` describe a registration nobody created. + */ +function ownershipBaseRecord(current: ServiceInstallState | null): ServiceInstallState { + if (current) return current; + const codexHome = currentCodexHome(); + return { + version: 2, + codexHome, + opencodexHome: currentOpenCodexHome(), + codexSqliteHome: resolveCodexSqliteHome({ codexHome }), + backend: "scheduler", + }; +} + +/** + * Record `claim` as the runtime's owner and return what was written. + * + * Idempotent by design: re-recording the same owner and install id leaves the consent + * generation alone, so every relaunch of an app that already has consent is a no-op on the + * number. A different owner or a different install id is a new grant and increments it once. + */ +export function recordServiceOwner( + claim: { owner: ServiceOwner; installId: string }, + deps: ServiceStateSwapDeps = {}, +): ServiceOwnership { + if (!claim.installId) throw new Error("refusing to record service ownership without an install id"); + let recorded: ServiceOwnership | null = null; + swapServiceInstallState(current => { + const previous = current?.ownership ?? null; + // The ceiling, not just the live claim: a grant that was released left its number + // behind on purpose, so a later grant cannot reuse it. + const floor = Math.max(previous?.consentGeneration ?? 0, current?.consentGenerationCeiling ?? 0); + recorded = { + owner: claim.owner, + installId: claim.installId, + consentGeneration: previous && ownershipGrantedTo(previous, claim.owner, claim.installId) + ? previous.consentGeneration + : floor + 1, + }; + return { + ...ownershipBaseRecord(current), + ownership: recorded, + consentGenerationCeiling: Math.max(floor, recorded.consentGeneration), + }; + }, deps); + if (recorded === null) throw new Error("service ownership was not recorded"); + return recorded; +} + +/** + * Drop a recorded owner and return what was dropped, or null when nothing was recorded. + * + * Writes nothing when there is no claim to release, so asking about an unowned runtime never + * creates an install record describing a service nobody registered. + */ +export function releaseServiceOwner(deps: ServiceStateSwapDeps = {}): ServiceOwnership | null { + let released: ServiceOwnership | null = null; + swapServiceInstallState(current => { + released = current?.ownership ?? null; + if (!current?.ownership) return null; + const { ownership: _released, ...withoutOwnership } = current; + // Keep the number. Dropping it makes the generation an ABA token: grant, release, grant + // again would produce 1 twice, and an app-local record still holding the first 1 would + // read the second grant as its own prior consent. + return { + ...withoutOwnership, + consentGenerationCeiling: Math.max( + current.consentGenerationCeiling ?? 0, + current.ownership.consentGeneration, + ), + }; + }, deps); + return released; +} + /** What ONE state path said. Absent, unreadable and invalid are different answers. */ export type ServiceStateEvidence = | { readonly path: string; readonly kind: "absent" } diff --git a/src/update/index.ts b/src/update/index.ts index 053f48dc22a..f8286e0afaa 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -9,6 +9,8 @@ import { dirname, join, resolve } from "node:path"; import { getConfigDir, loadConfig } from "../config"; import { readPid, readRuntimePort } from "../config/process-state"; import { pendingTeardownOutstanding } from "../config/pending-teardown"; +import type { ServiceOwnership } from "../service/state"; +import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; import { npmInvocation } from "./npm-invocation.mjs"; import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; import { detectInstallFromPath } from "./install-detection.mjs"; @@ -317,6 +319,24 @@ export function checkUpdatePackageIntegrity( return result; } +/** + * The recorded runtime owner, in the shape the shared update rule reads. + * + * Fails CLOSED. A resolution this process could not obtain is not evidence that nobody owns + * the runtime, and treating it as such is how an unreadable record reactivates the npm + * launcher over a takeover the user consented to. + */ +async function resolvedRuntimeOwnership(): Promise<{ ownership: ServiceOwnership | null; ownershipUnknown: boolean }> { + try { + const { resolveServiceOwnership } = await import("../service"); + const resolution = resolveServiceOwnership(); + if (resolution.kind === "owned") return { ownership: resolution.ownership, ownershipUnknown: false }; + return { ownership: null, ownershipUnknown: resolution.kind === "unknown" }; + } catch { + return { ownership: null, ownershipUnknown: true }; + } +} + /** * `ocx update` fallback for source checkouts and Bun global installs. npm and pnpm global installs * are updated in the Node bin launcher before Bun starts, so Windows does not replace the running @@ -382,6 +402,13 @@ export async function runUpdate(): Promise { const { isServiceInstalled } = await import("../service"); serviceWasInstalled = isServiceInstalled(); } catch { /* best-effort */ } + // What this update may do to the runtime. A desktop takeover vetoes both the stop and the + // service refresh below; see `planUpdateRuntimeHandling` for why each half is wrong. + const runtimePlan = planUpdateRuntimeHandling({ + ...(await resolvedRuntimeOwnership()), + serviceInstalled: serviceWasInstalled, + }); + if (runtimePlan.notice) console.log(runtimePlan.notice); let trayWasInstalled = false; let trayWasRunning = false; if (process.platform === "win32") { @@ -429,7 +456,7 @@ export async function runUpdate(): Promise { // silently skips the recovery the receipt was written to trigger (#3008). // Full `ocx stop` semantics (drain, service stop, restore). let stopAttempted = false; - if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) { + if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); @@ -588,7 +615,7 @@ export async function runUpdate(): Promise { // The stop above unloaded any managed service; repair it with the NEW files // (spawn the fresh cli.ts so updated code writes the baked paths) so a // launchd/schtasks/systemd user isn't left with the background proxy down. - if (serviceWasInstalled) { + if (runtimePlan.refreshService) { console.log("🔁 Refreshing the background service with the updated files..."); const { serviceReinstallArgs } = await import("../service"); const { reclaimListenPort } = await import("../server/port-reclaim"); @@ -638,6 +665,17 @@ export async function runUpdate(): Promise { ? ` Run 'ocx service repair', then 'ocx start --port ${capturedListen.port}'.` : ` Run 'ocx service repair' to see the reason, then 'ocx start --port ${capturedListen.port}'.`); } else { + // Re-read rather than reuse the plan from before the package install: the app can + // claim the runtime during an update that takes minutes, and the refusal the + // repair above just returned is indistinguishable from any other failure here. + const nowOwned = planUpdateRuntimeHandling({ + ...(await resolvedRuntimeOwnership()), + serviceInstalled: true, + }); + if (!nowOwned.stopRuntime) { + console.warn(nowOwned.notice ?? "⚠️ The background runtime is owned elsewhere; not starting a second proxy."); + return; + } console.warn( serviceRefreshed ? "⚠️ Service refresh left a non-viable manager (stale or missing assets) — starting the proxy directly instead." @@ -665,14 +703,14 @@ export async function runUpdate(): Promise { if (prevBake === undefined) delete process.env.OCX_BAKE_PORT; else process.env.OCX_BAKE_PORT = prevBake; } - } else { + } else if (runtimePlan.stopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); } } else { if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) { spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true }); } - if (stopAttempted && serviceWasInstalled && postUpdateLauncherUsable) { + if (stopAttempted && runtimePlan.refreshService && postUpdateLauncherUsable) { const service = spawnSync(process.execPath, [postUpdateLauncher, "service", "repair"], { stdio: "inherit", windowsHide: true, diff --git a/src/update/job.ts b/src/update/job.ts index 16310cad824..d3ce29ec9be 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -26,6 +26,7 @@ import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop"; import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness"; import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service"; +import { updateRestartVeto, type ServiceOwnershipResolution } from "./restart-ownership"; import { type Channel, type Installer, @@ -1804,6 +1805,8 @@ export interface GuiUpdateWorkerIo { resolvePnpmActiveLauncherFn?: (owner: PnpmGlobalOwner) => string | null; /** Restart seams used by focused worker tests; the verified launcher is always injected. */ restartIo?: RestartIo; + /** Resolves who owns the runtime; defaults to the shared service install state. */ + resolveOwnershipFn?: () => ServiceOwnershipResolution; runCommandFn?: ( job: UpdateJobState, bin: string, @@ -1967,6 +1970,10 @@ export async function runGuiUpdateWorker( } if (restart) { + // The package updater it just ran deliberately left a foreign-owned runtime alone, and + // restarting here would replace the app's sidecar with an npm proxy. + const veto = updateRestartVeto(io.resolveOwnershipFn); + if (veto) { updateJob(job, { status: "succeeded", restarted: false }, veto); return; } job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); if (!(await finishGuiUpdateRestart(job, captured, check.installer, { ...io.restartIo, diff --git a/src/update/restart-ownership.ts b/src/update/restart-ownership.ts new file mode 100644 index 00000000000..7e0471e379d --- /dev/null +++ b/src/update/restart-ownership.ts @@ -0,0 +1,32 @@ +import { resolveServiceOwnership } from "../service"; +import type { ServiceOwnershipResolution } from "../service"; +import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; + +export type { ServiceOwnershipResolution }; + +/** + * Why a dashboard update must not restart a runtime it did not stop. + * + * The worker defaults to restarting. When the package updater it just ran left a + * foreign-owned runtime alone — which is the correct behaviour under a desktop takeover — + * the sidecar's unchanged pid fails the worker's restart evidence, so it reclaims the port, + * runs the repair that now refuses, and falls through to a direct start. The app's runtime + * ends up replaced by an npm proxy immediately after the update declined to touch it. + * + * Returns the line to report, or null when the restart may proceed. It lives beside the + * worker rather than inside it because `src/update/job.ts` is one line under the repository + * size cap. + */ +export function updateRestartVeto( + resolve: () => ServiceOwnershipResolution = resolveServiceOwnership, +): string | null { + const owner = resolve(); + const plan = planUpdateRuntimeHandling({ + ownership: owner.kind === "owned" ? owner.ownership : null, + ownershipUnknown: owner.kind === "unknown", + // The restart decision does not refresh the service; only the stop veto is read here. + serviceInstalled: false, + }); + if (plan.stopRuntime) return null; + return plan.notice ?? "The background runtime is owned elsewhere; it was left running."; +} diff --git a/src/update/runtime-ownership.d.mts b/src/update/runtime-ownership.d.mts new file mode 100644 index 00000000000..02cd87fad08 --- /dev/null +++ b/src/update/runtime-ownership.d.mts @@ -0,0 +1,16 @@ +/** Declaration for the plain-ESM runtime-ownership rule shared with `bin/ocx.mjs`. */ +export declare function parseRecordedOwnership(raw: string | null | undefined): { + owner: string; + installId: string; + consentGeneration: number; +} | null; + +export declare function planUpdateRuntimeHandling(input: { + ownership: { owner: string; installId: string; consentGeneration: number } | null; + ownershipUnknown?: boolean; + serviceInstalled: boolean; +}): { + stopRuntime: boolean; + refreshService: boolean; + notice: string | null; +}; diff --git a/src/update/runtime-ownership.mjs b/src/update/runtime-ownership.mjs new file mode 100644 index 00000000000..a48339de972 --- /dev/null +++ b/src/update/runtime-ownership.mjs @@ -0,0 +1,97 @@ +/** + * Does an update own the runtime it is about to stop and restart? + * + * Both updaters ask this: `src/update/index.ts` on the Bun path and `bin/ocx.mjs` on the + * npm and pnpm path. It lives here as plain ESM for the same reason `stop-decision.mjs` + * does — the Node launcher has to be able to import it, and two lanes deciding the same + * situation separately is how a fix ships on one side only. + */ + +/** + * The ownership claim recorded in `service-state.json`, or null. + * + * Kept in step with `parseServiceOwnership` in `src/service/state.ts`, which is the + * authoritative reader; this one exists because the Node launcher cannot import TypeScript. + * `tests/update/update-desktop-owner.test.ts` drives the same accepted and rejected shapes + * through both so they cannot drift apart. + * + * @param {string | null | undefined} raw The state file's text, or null when it is absent. + * @returns {{ owner: string, installId: string, consentGeneration: number } | null} + */ +export function parseRecordedOwnership(raw) { + if (typeof raw !== "string" || raw.length === 0) return null; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const ownership = parsed.ownership; + if (!ownership || typeof ownership !== "object" || Array.isArray(ownership)) return null; + if (ownership.owner !== "cli" && ownership.owner !== "desktop") return null; + if (typeof ownership.installId !== "string" || ownership.installId.length === 0) return null; + const generation = ownership.consentGeneration; + if (typeof generation !== "number" || !Number.isInteger(generation) || generation < 0) return null; + return ownership; +} + +/** + * Decide how an update treats a runtime it may not own. + * + * `ocx update` replaces the package's files and then puts the proxy back: it stops the + * running server first, and afterwards runs `ocx service repair` to re-register and restart + * the background service. Under a desktop owner both halves are wrong. The running server is + * the app's own bundled sidecar rather than anything this package installed, so stopping it + * takes down a runtime the update has no way to bring back; and the repair would re-enable + * the npm launcher the takeover superseded, which is the exact reactivation the ownership + * marker exists to prevent. Neither half is needed either — the app updates its own runtime. + * + * The service registration itself is untouched in every case. It is kept by decision, not by + * accident, so a user who later runs `ocx service install` gets their npm service back. + * + * THE COST OF A STALE MARKER. This reads the recorded claim, not liveness. An app deleted + * without releasing ownership leaves a marker behind, and an update then declines to stop or + * refresh a runtime no app is managing any more. That is the orphan-recovery cost the + * two-record ownership design accepted; `ocx service install` clears the marker and restores + * the ordinary path. + * + * Both returned flags are VETOES, not commands: each updater already has its own reasons to + * stop the proxy and to refresh the service, and this plan can only take them away. + * + * THE LIMIT OF THIS RULE. It reads the recorded claim, not the live process. If the app was + * deleted and the user then starts an npm proxy by hand, the stale claim still vetoes the + * stop and the update replaces package files under a live server. Proving WHICH runtime is + * answering needs the identity the bundled CLI's resolve contract will carry; until then the + * notice tells the user how to clear the marker. + * + * @param {{ ownership: { owner: string, installId: string, consentGeneration: number } | null, ownershipUnknown?: boolean, serviceInstalled: boolean }} input + * @returns {{ stopRuntime: boolean, refreshService: boolean, notice: string | null }} + */ +export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, serviceInstalled }) { + // Unreadable, malformed or contradictory is not "nobody owns it". Reading it that way is + // how a permissions error reactivates the npm launcher over a consented takeover. + if (ownershipUnknown) { + return { + stopRuntime: false, + refreshService: false, + notice: "⚠️ The background runtime's recorded owner could not be determined, so it was " + + "left running and the service registration was not touched. " + + "Run 'ocx service install' to re-register the service and take the runtime back.", + }; + } + // Any owner that is not this CLI. Reading it this way rather than testing for "desktop" + // keeps a third kind of owner from silently falling into the branch that touches the npm + // registration. + if (ownership && ownership.owner !== "cli") { + return { + stopRuntime: false, + refreshService: false, + notice: `🖥️ The desktop app owns the background runtime (install ${ownership.installId}, ` + + `consent generation ${ownership.consentGeneration}). It was left running, and the ` + + "service registration was neither re-enabled nor restarted. " + + "If the desktop app is gone, run 'ocx service install' to take the runtime back.", + }; + } + return { stopRuntime: true, refreshService: serviceInstalled, notice: null }; +} diff --git a/structure/runtime.md b/structure/runtime.md index ec03516b8cc..80a82701a9b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -515,3 +515,48 @@ Unicode pattern normalization uses [copy-on-write traversal](transports/byte-acc Codex compaction uses a request-local model override for the configured triggers; the [Responses compaction contract](transports/responses.md#compaction-routing-overrides) owns its trigger and replay boundaries. + +## Background-service runtime ownership + +`src/service/state.ts` records who owns the running proxy in the shared service install +state, beside the install provenance. The claim carries an `owner` (`cli` or `desktop`), an +opaque `installId` naming the owning installation rather than the user or the machine, and a +`consentGeneration`. An absent claim means the CLI install that registered the service owns +the runtime, which is what every record written before the field existed says. + +Every write goes through `swapServiceInstallState`. It holds an `O_EXCL` lock beside the +anchor record for the whole read-modify-write, re-reads the anchor immediately before +committing and compares the committed bytes afterwards, and it runs the whole sequence again +when another writer landed inside that window; `revision` is the compare-and-swap token. The +lock excludes cooperating writers, and the revision check catches a writer that does not take +it, such as an older `ocx` on the same machine. `writeServiceInstallState` rebuilds only the +install provenance and carries the ownership claim across unchanged, which is what keeps an +install, a repair, an update or a stop from dropping it. + +`resolveServiceOwnership` is how a claim is read for a decision. It reads every state path +and answers `none`, `owned` or `unknown`; absence is the only thing that means no claim, so +an unreadable path, a corrupt anchor record, or paths naming different owners all refuse +rather than reading as CLI-owned. `consentGenerationCeiling` survives a release, so granting, +releasing and granting again cannot reuse a number an app-local record may still hold. + +`recordServiceOwner` is idempotent on the same owner and install id, so a relaunch leaves the +generation alone and a grant moves it exactly once. +`ownershipGrantedTo(ownership, owner, installId)` is the comparison an installation applies +to its own locally stored install id: true means this installation already holds consent, +false against a recorded claim means a different installation owns the runtime and consent +has to be asked again, and a null claim means the CLI install still owns it. + +The verbs that ACTIVATE the npm registration refuse on a foreign or unknown owner: +`src/service/repair.ts` stops before it asserts, writes, stops or starts anything, and +`ocx service start` reports the same refusal. `stop` and `uninstall` are not gated, because +they deactivate. `src/update/runtime-ownership.mjs` vetoes both the pre-update stop and the +post-update service refresh for all three update lanes — `src/update/index.ts`, +`bin/ocx.mjs` and the dashboard worker in `src/update/job.ts` — and the two package updaters +re-read the claim before any direct-start fallback, because an app can take the runtime during +an install that takes minutes. The registration is never deleted; `ocx service install` is the +one verb that releases the marker, and it does so only after the registration succeeded. + +The veto reads the recorded claim, not the live process. An app removed without releasing +leaves a stale claim, and proving which runtime is answering needs the identity the bundled +CLI's resolve contract will carry; until then the refusals name `ocx service install` as the +way to clear it. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c27b6771458..07135ba3e09 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1197,6 +1197,8 @@ "server-xai-header-parity.test.ts": "server", "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", + "service-ownership-handover.test.ts": "service", + "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", "service-secrets.test.ts": "service", "service-sqlite-home.test.ts": "service", @@ -1300,6 +1302,7 @@ "umans-provider.test.ts": "providers", "uninstall.test.ts": "cli", "update-badge.test.ts": "update", + "update-desktop-owner.test.ts": "update", "update-job.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", diff --git a/tests/service/launchd-repair.test.ts b/tests/service/launchd-repair.test.ts index c6c186c1188..abbdf0fe393 100644 --- a/tests/service/launchd-repair.test.ts +++ b/tests/service/launchd-repair.test.ts @@ -1052,7 +1052,10 @@ describe("the surfaces around the repair (#4236 defects 1f, 1h, 2)", () => { expect(filter).toContain("isProtectedHomeUnderTest(dirname(path))"); expect(filter).toContain("paths.filter("); expect(filter).toContain("refusing to write service install state"); - expect(slice(state, "function writeServiceInstallState(", "function readServiceInstallState(")) + // The fail-loud path resolution moved into the compare-and-swap writer when ownership + // became a preserved field. Same invariant, one layer down: nothing commits the record + // without resolving the write paths that refuse to write nowhere. + expect(slice(state, "export function swapServiceInstallState(", "/** The recorded owner of ONE")) .toContain("serviceStateWritePaths()"); }); diff --git a/tests/service/service-ownership-handover.test.ts b/tests/service/service-ownership-handover.test.ts new file mode 100644 index 00000000000..f482f86b17d --- /dev/null +++ b/tests/service/service-ownership-handover.test.ts @@ -0,0 +1,147 @@ +/** + * Who may hand the runtime back, and what refuses to take it. + * + * The maintainer decision behind this: the user's npm service registration is KEPT, never + * deleted. So the recorded owner is the only thing standing between a takeover the user + * consented to and the next `ocx service repair` — which runs incidentally, from a tray + * helper, from `ocx update`, from a doctor suggestion — re-enabling and restarting the npm + * launcher without saying a word about it. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; +import { foreignServiceOwnerRefusal, repairService, unknownServiceOwnerRefusal } from "../../src/service/repair"; +import type { ServiceDiagnostic } from "../../src/service/diagnostics"; +import type { ServiceOwnershipResolution } from "../../src/service/state"; + +const INSTALLED: ServiceDiagnostic = { + supported: true, installed: true, enabled: true, running: true, viable: true, + startable: true, stale: false, conflict: false, backend: "launchd", summary: "installed", +}; + +const DESKTOP_CLAIM = { owner: "desktop", installId: "app-install-a", consentGeneration: 2 } as const; +const DESKTOP: ServiceOwnershipResolution = { kind: "owned", ownership: DESKTOP_CLAIM }; +const UNKNOWN: ServiceOwnershipResolution = { kind: "unknown", reason: "a service state path could not be read (EACCES)" }; + +describe("repair under an owner that is not this CLI", () => { + test("refuses before it asserts, writes, stops or starts anything", async () => { + const touched: string[] = []; + await expect(repairService({ + platform: "darwin", + diagnose: () => INSTALLED, + readOwnership: () => DESKTOP, + assertEnv: () => { touched.push("assertEnv"); }, + assertAuth: () => { touched.push("assertAuth"); }, + repairLaunchd: () => { touched.push("repairLaunchd"); }, + restartLaunchd: () => { touched.push("restartLaunchd"); }, + })).rejects.toThrow(/desktop app owns the runtime/); + // A repair that has already rewritten the assets has changed the thing it was + // supposed to leave alone, so the gate has to sit in front of every seam. + expect(touched).toEqual([]); + }); + + test("restart refuses on the same terms", async () => { + await expect(repairService({ + platform: "darwin", + verb: "restart", + diagnose: () => INSTALLED, + readOwnership: () => DESKTOP, + repairLaunchd: () => { throw new Error("must not run"); }, + restartLaunchd: () => { throw new Error("must not run"); }, + })).rejects.toThrow(/desktop app owns the runtime/); + }); + + /** + * Collapsing "I could not read the claim" into "there is no claim" is how a permissions + * error reactivates a consented takeover. Only true absence may mean nobody owns it. + */ + test("an unreadable or contradictory record refuses too, rather than reading as CLI-owned", async () => { + const touched: string[] = []; + await expect(repairService({ + platform: "darwin", + diagnose: () => INSTALLED, + readOwnership: () => UNKNOWN, + assertEnv: () => { touched.push("assertEnv"); }, + repairLaunchd: () => { touched.push("repairLaunchd"); }, + })).rejects.toThrow(/could not be determined/); + expect(touched).toEqual([]); + }); + + test("both refusals name the untouched registration and the way back", () => { + const foreign = foreignServiceOwnerRefusal(DESKTOP_CLAIM); + expect(foreign).toContain("app-install-a"); + expect(foreign).toContain("consent generation 2"); + expect(foreign).toContain("not re-enabled, not rewritten and not restarted"); + expect(foreign).toContain("ocx service install"); + + const unknown = unknownServiceOwnerRefusal("a service state path could not be read (EACCES)"); + expect(unknown).toContain("EACCES"); + expect(unknown).toContain("ocx service install"); + // Both take the verb, so `start` does not report itself as a repair. + expect(foreignServiceOwnerRefusal(DESKTOP_CLAIM, "start")).toContain("Background service start stopped"); + expect(unknownServiceOwnerRefusal("nothing parsed", "start")).toContain("Background service start stopped"); + }); + + test("a CLI owner repairs normally, and so does a record with no claim at all", async () => { + const resolutions: ServiceOwnershipResolution[] = [ + { kind: "none" }, + { kind: "owned", ownership: { owner: "cli", installId: "npm-install", consentGeneration: 4 } }, + ]; + for (const resolution of resolutions) { + let repaired = false; + await repairService({ + platform: "darwin", + diagnose: () => INSTALLED, + readOwnership: () => resolution, + assertEnv: () => {}, + assertAuth: () => {}, + repairLaunchd: () => { repaired = true; }, + }); + expect(repaired).toBe(true); + } + }); + + test("an uninstalled service still reports that first", async () => { + await expect(repairService({ + platform: "darwin", + diagnose: () => ({ ...INSTALLED, installed: false }), + readOwnership: () => DESKTOP, + })).rejects.toThrow(/not installed/); + }); +}); + +describe("which service verbs are gated", () => { + const cli = readFileSync(repoPath("src", "service", "cli.ts"), "utf8"); + const between = (from: string, to: string): string => cli.slice(cli.indexOf(from), cli.indexOf(to)); + const installCase = between("case \"install\":", "case \"start\":"); + const startCase = between("case \"start\":", "case \"stop\""); + + /** + * Releasing first meant a cancelled UAC prompt, a failed registration or an aborted + * cleanup left the retained npm registration looking CLI-owned, so the next incidental + * repair would reactivate it. + */ + test("install releases the marker only after the registration succeeded", () => { + expect(installCase).toContain("releaseServiceOwner()"); + expect(installCase.indexOf("installServiceSafely")).toBeLessThan(installCase.indexOf("releaseServiceOwner()")); + // The failure branch leaves before the release. + expect(installCase.indexOf("Service install cleanup failed")).toBeLessThan(installCase.indexOf("releaseServiceOwner()")); + }); + + test("start refuses on the same terms, because it activates the registration", () => { + expect(startCase).toContain("resolveServiceOwnership()"); + expect(startCase).toContain("foreignServiceOwnerRefusal"); + expect(startCase).toContain("unknownServiceOwnerRefusal"); + // Reported, not thrown: the Windows tray drives this through a caller that does not catch. + expect(startCase).toContain("process.exitCode = 1;"); + }); + + test("stop and uninstall stay ungated, and nothing else releases the marker", () => { + const deactivating = cli.slice(cli.indexOf("case \"stop\"")); + expect(deactivating).not.toContain("resolveServiceOwnership"); + expect(deactivating).not.toContain("releaseServiceOwner"); + expect(startCase).not.toContain("releaseServiceOwner"); + expect(readFileSync(repoPath("src", "service", "repair.ts"), "utf8")).not.toContain("releaseServiceOwner"); + }); +}); + diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts new file mode 100644 index 00000000000..cf167a54fba --- /dev/null +++ b/tests/service/service-ownership-state.test.ts @@ -0,0 +1,334 @@ +/** + * Durable runtime ownership in the shared service install state. + * + * The behaviour under test is the one the plan calls R4: every install, repair, update and + * stop path reaches service-state.json through `writeServiceInstallState`, which used to + * rebuild the whole record and replace the file. Ownership recorded by a desktop takeover + * therefore lasted until the next repair — from a tray helper, from `ocx update`, from a + * doctor suggestion — and nothing said it had gone. + */ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { repoPath } from "../helpers/repo-root"; +import { + desktopOwnsService, + inspectServiceStateEvidence, + ownershipGrantedTo, + parseServiceInstallState, + parseServiceOwnership, + readServiceInstallState, + recordServiceOwner, + releaseServiceOwner, + resolveServiceOwnership, + ServiceStateConflictError, + serviceOwnership, + serviceStatePath, + serviceStatePaths, + swapServiceInstallState, + writeServiceInstallState, +} from "../../src/service/state"; + +let home: TempHome; +/** + * `serviceStatePaths()` deliberately includes the legacy `~/.opencodex/service-state.json` + * entry so an install made before OPENCODEX_HOME existed can still be found, and a write + * lands on BOTH. Under the suite that second path is the shared sandbox home, which outlives + * this file: a claim recorded here reappeared in `tests/service/service.test.ts` and in the + * dashboard update worker's tests, where the repair gate and the restart veto then fired on + * state those files never wrote. This fixture restores every state path it touched. + */ +let statePathSnapshot: { path: string; content: string | null }[] = []; + +beforeEach(() => { + home = createTempHome("ocx-service-ownership-"); + statePathSnapshot = serviceStatePaths().map(path => ({ + path, + content: existsSync(path) ? readFileSync(path, "utf8") : null, + })); +}); + +afterEach(() => { + for (const { path, content } of statePathSnapshot) { + if (content === null) { if (existsSync(path)) unlinkSync(path); } + else writeFileSync(path, content); + } + home.remove(); +}); + +const DESKTOP = { owner: "desktop", installId: "app-install-a" } as const; + +describe("ownership survives every install-state writer", () => { + test("a repair over a desktop takeover keeps the owner, the install id and the generation", () => { + const claimed = recordServiceOwner(DESKTOP); + expect(claimed).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); + + // What a repair does: rebuild the install provenance and write it. + writeServiceInstallState("scheduler", null); + + const after = readServiceInstallState(); + expect(after?.ownership).toEqual(claimed); + // The provenance half really was refreshed, so this is preservation rather than a + // write that quietly did nothing. + expect(after?.bunPath).toBeTruthy(); + expect(after?.backend).toBe("scheduler"); + expect(desktopOwnsService()).toBe(true); + }); + + test("a native-backend switch preserves the claim too", () => { + recordServiceOwner(DESKTOP); + writeServiceInstallState("native"); + const after = readServiceInstallState(); + expect(after?.backend).toBe("native"); + expect(after?.ownership?.installId).toBe("app-install-a"); + }); + + test("the writer that every subsystem calls preserves rather than rebuilds", () => { + const source = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); + const writer = source.slice( + source.indexOf("export function writeServiceInstallState("), + source.indexOf("export function readServiceInstallState("), + ); + expect(writer).toContain("swapServiceInstallState("); + expect(writer).toContain("current?.ownership"); + }); + + /** + * The conversion R4 asks for is one function deep because every writer already routes + * through it. This is what keeps that true: a module that composed the record itself, or + * reached for the raw swap, would reintroduce the replace-the-file behaviour in a place + * nobody would think to look. + */ + test("no service module composes or commits the install record itself", () => { + for (const file of ["orchestration.ts", "launchd.ts", "systemd.ts", "windows-ops.ts", "windows-scheduler.ts", "repair.ts"]) { + const source = readFileSync(repoPath("src", "service", file), "utf8"); + expect(source).toContain("writeServiceInstallState"); + expect(source).not.toContain("swapServiceInstallState"); + expect(source).not.toContain("service-state.json"); + expect(source).not.toMatch(/version:\s*2/); + } + }); +}); + +describe("consent generation and the comparison rule", () => { + test("a grant increments once; the same installation relaunching does not", () => { + expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); + expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); + expect(recordServiceOwner({ owner: "desktop", installId: "app-install-b" }).consentGeneration).toBe(2); + expect(recordServiceOwner({ owner: "cli", installId: "app-install-b" }).consentGeneration).toBe(3); + }); + + test("a grant belongs to one installation, not to the kind of owner", () => { + const ownership = recordServiceOwner(DESKTOP); + expect(ownershipGrantedTo(ownership, "desktop", "app-install-a")).toBe(true); + // A reinstalled app carries a different id and must ask for consent again. + expect(ownershipGrantedTo(ownership, "desktop", "app-install-b")).toBe(false); + expect(ownershipGrantedTo(ownership, "cli", "app-install-a")).toBe(false); + expect(ownershipGrantedTo(null, "desktop", "app-install-a")).toBe(false); + }); + + test("an install id is required, because an empty one would match nothing and claim everything", () => { + expect(() => recordServiceOwner({ owner: "desktop", installId: "" })).toThrow(/install id/); + }); + + test("releasing returns the dropped claim and creates no record when there is none", () => { + expect(releaseServiceOwner()).toBeNull(); + expect(existsSync(serviceStatePath())).toBe(false); + + writeServiceInstallState("scheduler", null); + recordServiceOwner(DESKTOP); + expect(releaseServiceOwner()).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); + expect(serviceOwnership()).toBeNull(); + expect(desktopOwnsService()).toBe(false); + // The install record itself is untouched: releasing ownership is not an uninstall. + expect(readServiceInstallState()?.bunPath).toBeTruthy(); + }); + + test("claiming with no install state writes no install provenance it cannot vouch for", () => { + recordServiceOwner(DESKTOP); + const record = readServiceInstallState(); + expect(record?.ownership?.owner).toBe("desktop"); + expect(record?.bunPath).toBeUndefined(); + expect(record?.launcherPath).toBeUndefined(); + }); +}); + +describe("the compare-and-swap", () => { + test("a writer that lands inside the commit window is detected and the swap recomputes", () => { + writeServiceInstallState("scheduler", null); + const before = readServiceInstallState()?.revision ?? 0; + + const result = swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { + beforeCommit: attempt => { + // Exactly one interleaved writer, on the first attempt only. + if (attempt === 0) recordServiceOwner(DESKTOP); + }, + }); + + // Both survive: the late claim because the swap re-read it, the launcher because the + // swap re-applied its own change to the newer base. + expect(result?.launcherPath).toBe("/opt/ocx"); + expect(result?.ownership?.installId).toBe("app-install-a"); + expect(readServiceInstallState()).toEqual(result!); + expect(result!.revision!).toBeGreaterThan(before + 1); + }); + + test("a swap that never wins gives up instead of overwriting the record", () => { + writeServiceInstallState("scheduler", null); + let competitors = 0; + expect(() => swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { + attempts: 3, + beforeCommit: () => { competitors += 1; recordServiceOwner({ owner: "desktop", installId: "app-" + competitors }); }, + })).toThrow(ServiceStateConflictError); + + expect(competitors).toBe(3); + // The last competitor's record stands, unmodified by the swap that lost. + const final = readServiceInstallState(); + expect(final?.ownership?.installId).toBe("app-3"); + expect(final?.launcherPath).toBeUndefined(); + }); + + test("every commit bumps the revision", () => { + writeServiceInstallState("scheduler", null); + const first = readServiceInstallState()?.revision; + writeServiceInstallState("scheduler", null); + expect(readServiceInstallState()?.revision).toBe(first! + 1); + }); + + test("a mutation that returns null writes nothing", () => { + writeServiceInstallState("scheduler", null); + const before = readFileSync(serviceStatePath(), "utf8"); + expect(swapServiceInstallState(() => null)?.revision).toBe(readServiceInstallState()?.revision); + expect(readFileSync(serviceStatePath(), "utf8")).toBe(before); + }); + + /** + * Unreadable is not absent. Reading a directory is the portable way to produce that + * answer; a real one is a permission the process does not have. Either way the swap has + * no base to preserve from, and computing one from an empty record is exactly how an + * ownership claim would be erased by a writer that was never allowed to see it. + */ + test("an unreadable record refuses the write instead of erasing what it cannot read", () => { + const unreadable = home.path("state-as-a-directory"); + mkdirSync(unreadable, { recursive: true }); + expect(() => swapServiceInstallState(() => ({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", + }), { paths: [unreadable] })).toThrow(/could not be read/); + }); +}); + +describe("parsing", () => { + const valid = { version: 2, codexHome: "/c", opencodexHome: "/o", backend: "scheduler" }; + + test("a malformed ownership claim invalidates the record rather than being dropped", () => { + expect(parseServiceInstallState({ ...valid, ownership: { owner: "desktop", installId: "a", consentGeneration: 1 } })?.ownership?.owner).toBe("desktop"); + expect(parseServiceInstallState({ ...valid, ownership: { owner: "desktop", installId: "a" } })).toBeNull(); + expect(parseServiceInstallState({ ...valid, ownership: { owner: "root", installId: "a", consentGeneration: 1 } })).toBeNull(); + expect(parseServiceInstallState({ ...valid, ownership: { owner: "cli", installId: "", consentGeneration: 1 } })).toBeNull(); + expect(parseServiceInstallState({ ...valid, ownership: { owner: "cli", installId: "a", consentGeneration: -1 } })).toBeNull(); + expect(parseServiceInstallState({ ...valid, ownership: "desktop" })).toBeNull(); + }); + + test("the revision must be a non-negative integer, and absent still parses", () => { + expect(parseServiceInstallState({ ...valid, revision: 0 })?.revision).toBe(0); + expect(parseServiceInstallState({ ...valid, revision: 1.5 })).toBeNull(); + expect(parseServiceInstallState({ ...valid, revision: -1 })).toBeNull(); + expect(parseServiceInstallState(valid)?.revision).toBeUndefined(); + }); + + test("a validated claim is returned as-is so a newer writer's fields survive a preserve", () => { + const ownership = { owner: "desktop", installId: "a", consentGeneration: 1, grantedBy: "first-launch" }; + expect(parseServiceOwnership(ownership)).toBe(ownership as never); + }); + + test("a record written before this field existed reads as CLI-owned, not unowned-and-free", () => { + writeFileSync(serviceStatePath(), JSON.stringify({ ...valid, codexHome: home.codexHome, opencodexHome: home.root })); + expect(serviceOwnership()).toBeNull(); + expect(desktopOwnsService()).toBe(false); + }); +}); + +describe("the record is read fail-closed", () => { + test("unreadable at any path is unknown, not unowned", () => { + const unreadable = home.path("unreadable-state"); + mkdirSync(unreadable, { recursive: true }); + const resolution = resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath(), unreadable])); + expect(resolution.kind).toBe("unknown"); + }); + + test("a corrupt anchor is unknown; corrupt legacy leftovers are ignored", () => { + writeFileSync(serviceStatePath(), "not json"); + expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath()])).kind).toBe("unknown"); + + // The second path is the legacy default-home entry. Junk left there by an old version + // must not be able to block every repair on the machine. + recordServiceOwner(DESKTOP); + const legacy = home.path("legacy-service-state.json"); + writeFileSync(legacy, "{ broken"); + const resolution = resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath(), legacy])); + expect(resolution).toEqual({ kind: "owned", ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 1 } }); + }); + + test("paths that name different owners are unknown", () => { + recordServiceOwner(DESKTOP); + const other = home.path("other-service-state.json"); + const record = JSON.parse(readFileSync(serviceStatePath(), "utf8")); + writeFileSync(other, JSON.stringify({ ...record, ownership: { ...record.ownership, installId: "app-install-b" } })); + expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath(), other])).kind).toBe("unknown"); + }); + + test("absent everywhere is the only thing that means no claim", () => { + expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath()]))).toEqual({ kind: "none" }); + }); + + /** + * Pre-existing, and it is why this had to be fixed here: cliEntry() returns null for a + * standalone binary, so every standalone install wrote a record its own parser rejected. + * After ownership moved into that record, an unparseable record reads as "nobody owns the + * runtime" — the exact demotion the claim exists to prevent. + */ + test("a standalone install record parses, so its ownership is readable at all", () => { + const standalone = { version: 2, codexHome: "/c", opencodexHome: "/o", bunPath: "/b", cliPath: null, backend: "scheduler" }; + expect(parseServiceInstallState(standalone)).not.toBeNull(); + expect(parseServiceInstallState(JSON.parse(JSON.stringify(standalone)))).not.toBeNull(); + expect(parseServiceInstallState({ ...standalone, cliPath: "" })).toBeNull(); + }); +}); + +describe("the generation cannot be reused", () => { + test("a release keeps the high-water mark so the next grant does not repeat it", () => { + expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); + releaseServiceOwner(); + expect(readServiceInstallState()?.consentGenerationCeiling).toBe(1); + // Without the ceiling this would be 1 again, and an app-local record still holding the + // first 1 would read the second grant as its own prior consent. + expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(2); + }); + + test("an ordinary install-state write carries the ceiling forward", () => { + recordServiceOwner(DESKTOP); + releaseServiceOwner(); + writeServiceInstallState("scheduler", null); + expect(readServiceInstallState()?.consentGenerationCeiling).toBe(1); + expect(recordServiceOwner({ owner: "desktop", installId: "app-install-b" }).consentGeneration).toBe(2); + }); +}); + +describe("the anchor lock", () => { + test("a lock another process holds blocks the write rather than racing it", () => { + writeFileSync(serviceStatePath() + ".lock", ""); + expect(() => swapServiceInstallState(() => ({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", + }), { lockWaitMs: 50 })).toThrow(/another process is writing/); + // Nothing was written: the swap never reached a commit. + expect(existsSync(serviceStatePath())).toBe(false); + }); + + test("a swap nested inside another one is not a race and does not deadlock", () => { + writeServiceInstallState("scheduler", null); + const result = swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { + beforeCommit: attempt => { if (attempt === 0) recordServiceOwner(DESKTOP); }, + }); + expect(result?.ownership?.installId).toBe("app-install-a"); + }); +}); diff --git a/tests/service/service-start-environment.test.ts b/tests/service/service-start-environment.test.ts index a858e4f90a9..6157bee3e97 100644 --- a/tests/service/service-start-environment.test.ts +++ b/tests/service/service-start-environment.test.ts @@ -28,7 +28,12 @@ test("service start refuses a changed install environment before it starts the l const body = source.slice(source.indexOf("export async function serviceCommand")); expect(body).toContain('case "start":'); - const startCase = body.slice(body.indexOf('case "start":')); - expect(startCase).toMatch(/assertServiceEnvironmentMatchesInstall\(\);\s*ops\.start\(\);/); + const startCase = body.slice(body.indexOf('case "start":'), body.indexOf('case "stop"')); + const guardAt = startCase.indexOf("assertServiceEnvironmentMatchesInstall();"); + const launchAt = startCase.indexOf("ops.start();"); + expect(guardAt).toBeGreaterThan(-1); + expect(launchAt).toBeGreaterThan(guardAt); + // The runtime-ownership refusal now sits between them. It can only PREVENT the start, so + // the invariant is unchanged: nothing reaches the service manager before the guard has run. + expect(startCase.slice(guardAt, launchAt)).not.toContain("ops."); }); - diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts new file mode 100644 index 00000000000..3300a3d051f --- /dev/null +++ b/tests/update/update-desktop-owner.test.ts @@ -0,0 +1,173 @@ +/** + * What an update may do to a runtime it does not own. + * + * Two updaters reach the same situation: the Bun path in src/update/index.ts and the npm + * and pnpm path in bin/ocx.mjs. Both stop the proxy and then run `ocx service repair`, and + * under a desktop owner both halves are wrong — the running server is the app's bundled + * sidecar, and the repair re-enables the npm launcher the takeover superseded. The rule is + * one plain-ESM module for the reason #3008 recorded: two lanes deciding separately is how + * a fix ships on one side only. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; +import { parseRecordedOwnership, planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; +import { parseServiceOwnership } from "../../src/service/state"; + +describe("the runtime-ownership veto", () => { + test("a desktop owner stops both the stop and the service refresh, and says so", () => { + const plan = planUpdateRuntimeHandling({ + ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 3 }, + serviceInstalled: true, + }); + expect(plan.stopRuntime).toBe(false); + expect(plan.refreshService).toBe(false); + expect(plan.notice).toContain("app-install-a"); + expect(plan.notice).toContain("consent generation 3"); + expect(plan.notice).toContain("neither re-enabled nor restarted"); + }); + + test("a CLI owner and an unowned runtime both take the ordinary path", () => { + for (const ownership of [null, { owner: "cli", installId: "npm-install", consentGeneration: 1 }]) { + expect(planUpdateRuntimeHandling({ ownership, serviceInstalled: true })) + .toEqual({ stopRuntime: true, refreshService: true, notice: null }); + expect(planUpdateRuntimeHandling({ ownership, serviceInstalled: false })) + .toEqual({ stopRuntime: true, refreshService: false, notice: null }); + } + }); + + test("an owner this version does not recognise is treated as foreign, not as our own", () => { + const plan = planUpdateRuntimeHandling({ + ownership: { owner: "something-newer", installId: "x", consentGeneration: 1 }, + serviceInstalled: true, + }); + expect(plan.stopRuntime).toBe(false); + }); +}); + +describe("the launcher's reader agrees with the authoritative one", () => { + const accepted = [ + { owner: "desktop", installId: "a", consentGeneration: 0 }, + { owner: "cli", installId: "a", consentGeneration: 12 }, + { owner: "desktop", installId: "a", consentGeneration: 1, grantedBy: "first-launch" }, + ]; + const rejected = [ + { owner: "root", installId: "a", consentGeneration: 1 }, + { owner: "desktop", installId: "", consentGeneration: 1 }, + { owner: "desktop", installId: "a" }, + { owner: "desktop", installId: "a", consentGeneration: -1 }, + { owner: "desktop", installId: "a", consentGeneration: 1.5 }, + { owner: "desktop", installId: "a", consentGeneration: "1" }, + "desktop", + null, + ]; + + test("both accept the same claims", () => { + for (const ownership of accepted) { + expect(parseServiceOwnership(ownership)).not.toBeNull(); + expect(parseRecordedOwnership(JSON.stringify({ ownership }))).toEqual(ownership); + } + }); + + test("both reject the same claims", () => { + for (const ownership of rejected) { + expect(parseServiceOwnership(ownership)).toBeNull(); + expect(parseRecordedOwnership(JSON.stringify({ ownership }))).toBeNull(); + } + }); + + test("an absent, empty or unparseable record is not a claim", () => { + expect(parseRecordedOwnership(null)).toBeNull(); + expect(parseRecordedOwnership("")).toBeNull(); + expect(parseRecordedOwnership("{")).toBeNull(); + expect(parseRecordedOwnership("[]")).toBeNull(); + expect(parseRecordedOwnership(JSON.stringify({ version: 2 }))).toBeNull(); + }); +}); + +describe("both updaters consult the shared rule", () => { + const bunPath = readFileSync(repoPath("src", "update", "index.ts"), "utf8"); + const launcher = readFileSync(repoPath("bin", "ocx.mjs"), "utf8"); + + test("the Bun updater gates its stop, its refresh and its restart hint", () => { + expect(bunPath).toContain("from \"./runtime-ownership.mjs\""); + expect(bunPath).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(bunPath).toContain("if (runtimePlan.refreshService) {"); + expect(bunPath).toContain("} else if (runtimePlan.stopRuntime) {"); + expect(bunPath).toContain("if (stopAttempted && runtimePlan.refreshService && postUpdateLauncherUsable)"); + }); + + test("the npm launcher gates its stop, its refresh and its failure recovery", () => { + expect(launcher).toContain("from \"../src/update/runtime-ownership.mjs\""); + expect(launcher).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(launcher).toContain("if (runtimePlan.refreshService) {"); + // Nothing was stopped, so nothing is recovered: starting a proxy here would put a + // second one beside the runtime the app is managing. + expect(launcher).toContain("if (!runtimePlan.stopRuntime) return;"); + }); + + test("neither updater reimplements the decision", () => { + for (const source of [bunPath, launcher]) { + expect(source).toContain("planUpdateRuntimeHandling({"); + expect(source).not.toMatch(/owner\s*!==\s*"cli"/); + } + }); +}); + +describe("an unreadable record is not an unowned runtime", () => { + test("unknown ownership vetoes both halves and points at the way back", () => { + const plan = planUpdateRuntimeHandling({ ownership: null, ownershipUnknown: true, serviceInstalled: true }); + expect(plan.stopRuntime).toBe(false); + expect(plan.refreshService).toBe(false); + expect(plan.notice).toContain("could not be determined"); + expect(plan.notice).toContain("ocx service install"); + }); + + test("the desktop notice also says how to clear a stale marker", () => { + const plan = planUpdateRuntimeHandling({ + ownership: { owner: "desktop", installId: "a", consentGeneration: 1 }, + serviceInstalled: true, + }); + expect(plan.notice).toContain("ocx service install"); + }); +}); + +describe("every updater re-reads ownership before it starts a proxy directly", () => { + const bunPath = readFileSync(repoPath("src", "update", "index.ts"), "utf8"); + const launcher = readFileSync(repoPath("bin", "ocx.mjs"), "utf8"); + const worker = readFileSync(repoPath("src", "update", "job.ts"), "utf8"); + + /** + * Ownership is sampled before a package install that can take minutes. If the app claims + * the runtime during it, the post-update repair refuses — and both callers used to read + * that refusal as a generic failure and start an npm proxy beside the app's sidecar. + */ + test("the two package updaters re-resolve before the direct-start fallback", () => { + for (const source of [bunPath, launcher]) { + const fallbackAt = source.indexOf("starting the proxy directly instead"); + expect(fallbackAt).toBeGreaterThan(-1); + const recheckAt = source.lastIndexOf("planUpdateRuntimeHandling({", fallbackAt); + expect(recheckAt).toBeGreaterThan(-1); + expect(source.slice(recheckAt, fallbackAt)).toContain("nowOwned.stopRuntime"); + } + }); + + /** + * The dashboard is a third lane. It defaults to restarting, and after the package updater + * correctly left a foreign-owned runtime alone it would reclaim the port, run the repair + * that now refuses, and fall through to a direct start. + */ + test("the dashboard worker checks before it restarts anything", () => { + const restartAt = worker.indexOf("if (restart) {"); + const handoffAt = worker.indexOf("finishGuiUpdateRestart(", restartAt); + const gateAt = worker.indexOf("updateRestartVeto(", restartAt); + expect(gateAt).toBeGreaterThan(restartAt); + expect(gateAt).toBeLessThan(handoffAt); + expect(worker.slice(gateAt, handoffAt)).toContain("if (veto)"); + expect(worker.slice(gateAt, handoffAt)).toContain("restarted: false"); + // The veto is the shared rule, not a second opinion about ownership. + const veto = readFileSync(repoPath("src", "update", "restart-ownership.ts"), "utf8"); + expect(veto).toContain("planUpdateRuntimeHandling({"); + expect(veto).toContain("resolveServiceOwnership"); + }); +}); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 0c21fce698c..c556bd34249 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -534,7 +534,9 @@ describe("update stops the running proxy before replacing files", () => { expect(stopAt).toBeGreaterThan(-1); expect(updateAt).toBeGreaterThan(-1); expect(stopAt).toBeLessThan(updateAt); - expect(updateSource).toContain("if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())"); + // The four signals are now inside a runtime-ownership veto: a desktop-owned runtime is + // not stopped at all. Every original reason to stop still reaches the gate unchanged. + expect(updateSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); }); test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => { @@ -783,8 +785,8 @@ esac // A pending-teardown receipt is a fourth reason to stop: after a parent crashed // mid-deferral the service, pid and runtime records can all be absent while shared // client config still points at a proxy that is gone (#3008). - expect(updateSource).toContain("if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())"); - expect(launcherSource).toContain("if (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)"); + expect(updateSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(launcherSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); // The rule now lives in the shared post-stop decision both lanes import (#3008): a // history-only stop proceeds, every other nonzero status and any surviving runtime // state aborts. Pinned by tests/update/update-stop-classification.test.ts. From d6c256ebef3dfb277c09a827e00b2329f41aac87 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:06:34 +0900 Subject: [PATCH 61/87] docs(devlog): open the remaining-seams unit for the next release (#5392) --- .../_plan/260921_remaining_seams/000_plan.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 devlog/_plan/260921_remaining_seams/000_plan.md diff --git a/devlog/_plan/260921_remaining_seams/000_plan.md b/devlog/_plan/260921_remaining_seams/000_plan.md new file mode 100644 index 00000000000..84aa1d2a45e --- /dev/null +++ b/devlog/_plan/260921_remaining_seams/000_plan.md @@ -0,0 +1,104 @@ +# The seams the first batch left open + +Status: open. Target branch for every lane: `dev`. + +The cross-path unit fixed each contract where it was written. Re-reading the +integrated tree found four places where those fixed policies stop one layer +short: an endpoint the refusal never reached, a name that changes between the +check and the thing checked, a match that accepts more than it was given, and a +pair of builders that read different halves of the same provider setting. + +Each lane is one branch, ordered commits, and one pull request to `dev`. + +## Owned files + +| Lane | Owns | +|---|---| +| N1 | `src/server/claude-messages.ts` error path and its tests | +| N2 | `src/server/responses-request-tool-scope.ts`, the scope call site in `src/server/responses/passthrough-delivery.ts`, `src/responses/muse-tool-name-alias.ts` where identity is carried, and the composition test | +| N3 | `src/adapters/openai-chat/passthrough.ts`, the shared wire policy it and `src/adapters/openai-chat.ts` both call, and its tests | +| N4 | `src/integrations/merge.ts` selector parsing and its tests | + +N2 owns the scope module outright; N1 and N3 do not touch it. + +## N1 — the refusal stops at the Claude Messages wrapper + +`src/server/chat-completions.ts`, `src/server/chat-native.ts` and +`src/server/responses/passthrough-error.ts` now agree: an ambiguous connection +loss answers with `upstream_reset_replay_refused`, no `Retry-After`, and +`x-should-retry: false`. `src/server/claude-messages.ts` rebuilds the error +envelope itself. It keeps only the message string, runs the generic +`resolveClientRetryAfter`, and emits an Anthropic error with `Content-Type` and +`Retry-After` — so the refusal reaches the caller as an ordinary retryable +rate limit. Anthropic's own client reads `x-should-retry` before the status +code, so the header is the part that actually stops the resend. + +Read the shared verdict here rather than re-deriving it from the message text, +and carry the code, the header and the suppressed `Retry-After` through. Two +behaviours must survive: the transient-5xx to 529 mapping the Claude client +depends on for backoff, and an ordinary provider 429, which keeps the retry +policy it has today. The acceptance is the header and code observed at +`/v1/messages`, not the internal Responses result. + +## N2 — identity has to survive the rename + +A long client tool name is sent upstream under a short alias, and +`tool_choice` is rewritten to that alias with it. On the way back the payload +rewrites restore the client name first, and only then does the snapshot repair +check the call against the scope built from the outbound body, which still +spells the selector as the alias. The restored name is not the alias, so an +allowed call is removed from the reconstructed terminal output and the turn ends +incomplete. + +The same module accepts too much in the other direction. A call is matched by +any of its spellings — bare name, `namespace__name`, `namespace.name` — against +a set holding the selector's spellings, so `alpha.lookup` and `beta.lookup` +both offer bare `lookup` and match each other. The selection set is a set of +strings, so a `custom` and a `function` tool of the same name are not separated +either; the fix is to distinguish a verified conversion from a coincidence of +names, not to refuse every kind mismatch. + +Carry the correspondence between the original identity, the wire alias and the +restored identity from the request, and have the scope read that correspondence. +`src/responses/namespace-tool-compat.ts` already reasons about selector kinds +and dotted-alias ambiguity; reuse it rather than growing a second, looser name +set. The regression test has to run the real order — a tool name past the length +limit, a named or allowed-tools selector, alias on the way out, restore on the +way back, sparse terminal reconstruction — and end with the original name and +call id intact. Keep the negative case: a tool the request did not select is +still refused after restoration. + +## N3 — two builders, one provider setting + +The translated builder turns `reasoningWireFormat: "gateway-object"` with an +effort of `none` into the gateway's object form, and omits the effort entirely +for a tool-bearing request when the model is listed in +`omitReasoningEffortWithToolsModels`. The native Chat passthrough reads neither, +so the same provider and model behave differently depending on whether a routing +feature sent the request through translation. + +Apply the explicit settings through one small policy both builders call, after +the provider is resolved. Do not route native requests through translation to +get it: the native path exists to preserve Chat-only fields such as `n`, audio +and logprobs, and losing those is a worse regression than the one being fixed. +An unset setting keeps today's native behaviour. The test compares the final +request body captured on both paths for the same input, not the status code. + +## N4 — a selector path must not change meaning + +The integration merge grammar gained a conjunction form, `[field=value,field=value]`, +because one field is not always an identity. The single-criterion form allows a +comma inside the value, so a path already written into an ownership record — for +example one whose value itself contains `,` and `=` — can parse as a conjunction +under the new rule and select a different element. + +No record in that shape has been found, so this is a migration hazard rather +than a reported loss. Close it deliberately: version the grammar, structure the +selector, or define an escape, and cover it with a test that reads a record +written under the older rule and asserts it still names the same element. + +## Out of scope + +The paginated-history work and the client provider store landed and are not +reopened here. The two retry issues left open after the first batch keep their +recorded scope; neither is a lane in this unit. From 34ddb4d5fdfebd94ba585b98e3ee31713c4f68cc Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:16:53 +0900 Subject: [PATCH 62/87] fix(release): pipeline correctness for Windows packaging, checksums, publication ordering, and process identity (#5388) * fix(release): run Windows packaging scripts under bash and record verifiable checksums The Windows desktop packaging lane ran a bash script under the runner default shell: Rename release assets uses backslash continuations and "$VAR" expansion with no shell: bash, and the workflow sets no defaults.run.shell, so a Windows runner reads it as PowerShell. The standalone archive step also wrote checksums naming a dist/ path, while attach-release verifies them with shasum -c from inside dist/release, where no dist/ directory exists. The step now declares shell: bash, and the checksum records the bare file name it is verified against. The contract is enforced by tests/ci-workflows/release-pipeline-contract.test.ts: every multi-line script on a Windows runner must declare an explicit shell, and a checksum argument must not carry a directory prefix the verifier cannot resolve. Both were driven red against the unfixed workflow before this change. * fix(release): gate npm publication and the GitHub release on packaging publish declared needs: validate-dispatch only, so npm publication and the GitHub release could proceed while desktop packaging was still failing, which is how a version becomes public with no app attached. The job now waits for package-standalone and package-desktop, matching the ordering attach-release already demanded of it. The release-pipeline contract test asserts the publish and attach-release dependency sets, and the long-standing assertion in ci-workflows.test.ts that encoded the old single-dependency shape is updated to the gated one. Driven red before the change. * fix(ci): trigger service lifecycle on the whole service directory and the desktop shell The lifecycle workflow and the release gate both keyed on src/service.ts, but the service implementation is the eleven-file src/service/ directory, and neither covered desktop/, whose shell packages and launches the service. A change to launchd.ts or windows-scheduler.ts alone produced no lifecycle run, and a release touching those files would have gated on evidence that never ran. Both triggers and the release gate regex now cover src/service/** and desktop/**, kept in sync in both files. The contract test derives the service module and desktop surface lists from the tree itself rather than restating them, so a new file that falls outside the gate fails the test. Driven red before the change. * fix(config): recognise the standalone Windows ocx.exe in process identity isOcxCommandLine matched ocx and ocx.cmd but not ocx.exe, even though scripts/build-standalone.ts and desktop/scripts/prepare-sidecar.ts both emit ocx.exe on Windows targets and the bundled sidecar is copied as ocx-.exe. The predicate feeds pid identity, so the shipped Windows binary was the one shape the identity check did not know: a service restart or a receipt-backed stop could not tell its own process from a foreign one. The executable token now accepts .exe as well as .cmd, so a quoted install path with spaces and any letter case matches, while lookalikes such as not-ocx.exe, myocx.exe and ocx.exes stay foreign. Driven red in tests/service/process-state.test.ts before the change. * fix(desktop): force the management client direct to loopback ProxyClient sends the admin token to 127.0.0.1, but reqwest resolves system proxy configuration by default, so a machine-wide proxy would receive a credential that is only ever meant for loopback. The builder now disables proxy resolution explicitly. tests/clients/desktop-proxy-direct-transport.test.ts asserts the builder carries no_proxy(), driven red before the change. --- .github/workflows/release.yml | 18 +- .github/workflows/service-lifecycle.yml | 8 + desktop/src-tauri/src/proxy.rs | 4 + scripts/test-layout/layout.json | 4 +- src/config/process-state.ts | 2 +- tests/ci-workflows/ci-workflows.test.ts | 6 +- .../release-pipeline-contract.test.ts | 174 ++++++++++++++++++ .../desktop-proxy-direct-transport.test.ts | 23 +++ tests/fixtures/test-layout-expected.json | 4 +- tests/service/process-state.test.ts | 17 ++ 10 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 tests/ci-workflows/release-pipeline-contract.test.ts create mode 100644 tests/clients/desktop-proxy-direct-transport.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6aa845877ca..fd99a10a9c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,9 +155,12 @@ jobs: else tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui fi - cd ../../.. - if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" - else sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + cd ../.. + # shasum -c in attach-release runs from dist/release, where the artifact download + # lands these files flat; the checksum therefore records the bare file name, which + # sha256sum takes verbatim from its argument. + if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" fi - name: Upload standalone release @@ -345,6 +348,7 @@ jobs: run: bunx tauri build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} - name: Rename release assets + shell: bash env: RELEASE_VERSION: ${{ inputs.version }} DESKTOP_TARGET: ${{ matrix.target }} @@ -481,7 +485,7 @@ jobs: gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber publish: - needs: validate-dispatch + needs: [validate-dispatch, package-standalone, package-desktop] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -632,8 +636,10 @@ jobs: fi # Keep in sync with the service-lifecycle.yml trigger paths. src/cli.ts is - # the pre-restructure compat stub that durable launchers still execute. - if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml|\.github/workflows/release\.yml)$'; then + # the pre-restructure compat stub that durable launchers still execute; the + # service implementation itself is the src/service/ directory, and the desktop + # shell packages and launches it. + if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/service/.*|desktop/.*|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml|\.github/workflows/release\.yml)$'; then service_url="$( gh run list \ --workflow service-lifecycle.yml \ diff --git a/.github/workflows/service-lifecycle.yml b/.github/workflows/service-lifecycle.yml index df37f605618..af6d3f34504 100644 --- a/.github/workflows/service-lifecycle.yml +++ b/.github/workflows/service-lifecycle.yml @@ -5,6 +5,11 @@ on: branches: [main, dev] paths: - "src/service.ts" + # The service implementation is the src/service/ directory; src/service.ts is only + # the pre-restructure compat facade. The desktop shell packages and launches the + # service, so its changes carry lifecycle evidence too. + - "src/service/**" + - "desktop/**" # Keep in sync with the release.yml service-gate regex (release.yml "Require # successful Cross-platform CI" step). src/cli.ts is the pre-restructure compat # stub that durable launchers still execute. @@ -22,6 +27,9 @@ on: paths: - "src/service.ts" # Keep in sync with the release.yml service-gate regex (see above). + - "src/service/**" + - "desktop/**" + # Keep in sync with the release.yml service-gate regex (see above). - "src/cli.ts" - "src/cli/index.ts" - "src/lib/bun-runtime.ts" diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index fc8dc71ec5e..caea7288563 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -24,6 +24,10 @@ impl ProxyClient { client: Client::builder() .timeout(Duration::from_secs(4)) .user_agent(Auth::user_agent()) + // The admin token attached to these requests is for loopback only. + // reqwest honours system proxy configuration by default, which would + // route the credential through whatever proxy the machine declares. + .no_proxy() .build()?, endpoint, auth, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ac55bc77987..c6aee6166be 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1636,7 +1636,9 @@ "ambiguous-resend-gate.test.ts": "lib", "docs-developer-role-policy.test.ts": "ci-workflows", "ambiguous-resend-composition.test.ts": "lib", - "chat-inbound-developer-position.test.ts": "responses" + "chat-inbound-developer-position.test.ts": "responses", + "release-pipeline-contract.test.ts": "ci-workflows", + "desktop-proxy-direct-transport.test.ts": "clients" }, "migrated": [ "adapters", diff --git a/src/config/process-state.ts b/src/config/process-state.ts index b42a022894c..79d40a0ffc3 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -127,7 +127,7 @@ export function isOcxCommandLine(commandLine: string): boolean { || normalized.includes("src/cli/index.ts") || normalized.includes("@bitkyc08/opencodex") || /@bitkyc08\/\.opencodex-/.test(normalized) - || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized); + || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd|\.exe)?(?:$|[\s"'])/.test(normalized); } export function isOcxStartCommandLine(commandLine: string): boolean { diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 6d7959c3e57..2b79f6b4cc8 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -828,7 +828,7 @@ describe("GitHub Actions hardening", () => { }; publish?: { "runs-on"?: string; - needs?: string; + needs?: string[]; permissions?: Record; }; }; @@ -843,7 +843,9 @@ describe("GitHub Actions hardening", () => { contents: "read", }); - expect(release.jobs?.publish?.needs).toBe("validate-dispatch"); + // Publication is the irreversible public act, so it waits for both packaging jobs; + // the full ordering contract is in tests/ci-workflows/release-pipeline-contract.test.ts. + expect(release.jobs?.publish?.needs).toEqual(["validate-dispatch", "package-standalone", "package-desktop"]); expect(release.jobs?.publish?.["runs-on"]).toBe("ubuntu-latest"); expect(release.jobs?.publish?.permissions).toEqual({ contents: "write", diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts new file mode 100644 index 00000000000..8f8ff47875a --- /dev/null +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +type WorkflowStep = { + name?: string; + if?: string; + uses?: string; + with?: Record; + run?: string; + shell?: string; +}; + +type WorkflowJob = { + needs?: string[]; + strategy?: { matrix?: { include?: Array<{ os?: string }> } }; + steps?: WorkflowStep[]; +}; + +type Workflow = { jobs?: Record }; + +function needsOf(job: WorkflowJob | undefined): string[] { + if (job?.needs === undefined) return []; + return typeof job.needs === "string" ? [job.needs] : job.needs; +} + +function readWorkflow(...segments: string[]): Workflow { + return Bun.YAML.parse(readFileSync(repoPath(...segments), "utf8")) as Workflow; +} + +function triggerPaths(workflowText: string, trigger: string, until: string): string[] { + const afterTrigger = workflowText.split(`${trigger}:`)[1]?.split(`${until}:`)[0]; + expect(afterTrigger).toBeDefined(); + return afterTrigger! + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith('- "')) + .map(line => line.slice(3, -1)); +} + +/** + * Contracts for the release pipeline itself. Each assertion encodes a defect that a green + * workflow can still carry: a bash script silently reinterpreted by PowerShell on a Windows + * runner, a checksum that records a path its verifier cannot resolve, publication that outruns + * packaging, and a service gate that names one file while the implementation is a directory. + */ +describe("release pipeline contract", () => { + const release = readWorkflow(".github", "workflows", "release.yml"); + + test("every multi-line script on a Windows runner declares an explicit shell", () => { + for (const jobId of ["package-standalone", "package-desktop"]) { + const job = release.jobs?.[jobId]; + expect(job, jobId).toBeDefined(); + const runsOnWindows = job!.strategy?.matrix?.include?.some(entry => entry.os === "windows-latest"); + expect(runsOnWindows, `${jobId} exercises Windows`).toBe(true); + for (const step of job!.steps ?? []) { + // A single command line is shell-neutral; a script block implies shell-specific + // syntax and must not fall back to the runner's default shell on Windows. + if (typeof step.run !== "string" || !step.run.includes("\n")) continue; + // Steps fenced away from Windows never meet PowerShell. + if (/runner\.os\s*==\s*'(Linux|macOS)'/.test(step.if ?? "")) continue; + expect(step.shell, `${jobId} / ${step.name}`).toBeDefined(); + } + } + }); + + test("the release asset rename runs under bash", () => { + const step = release.jobs?.["package-desktop"]?.steps + ?.find(candidate => candidate.run?.includes("collect-release-assets.ts")); + expect(step).toBeDefined(); + // The script uses backslash continuations and "$VAR" expansion, which PowerShell does + // not read the way bash does; on the Windows matrix this step is only correct under bash. + expect(step!.shell).toBe("bash"); + }); + + test("standalone checksums record bare names that resolve where the verifier runs", () => { + const archive = release.jobs?.["package-standalone"]?.steps + ?.find(candidate => candidate.run?.includes("sha256sum")); + expect(archive).toBeDefined(); + const checksumLines = archive!.run!.split("\n") + .filter(line => line.includes("sha256sum") && !line.trim().startsWith("#")); + expect(checksumLines.length).toBeGreaterThanOrEqual(2); + for (const line of checksumLines) { + const argument = /sha256sum\s+"([^"]+)"/.exec(line)?.[1]; + expect(argument, line).toBeDefined(); + // shasum -c resolves the recorded path relative to the verifier's working directory, + // which is dist/release; any directory prefix names a file that cannot exist there. + expect(argument!).not.toContain("/"); + // The redirect target gets the same treatment: a bare output name is what makes the + // checksum file land in the directory the upload glob scans. + const output = />\s+"([^"]+)"/.exec(line)?.[1]; + expect(output, line).toBeDefined(); + expect(output!).not.toContain("/"); + } + + // The bare names above only resolve end to end if the step checksums from the directory + // the artifact lives in (it leaves the per-target build directory first), if the upload + // glob picks the checksum file up, and if the download flattens every artifact beside + // the verifier. Locking only the final shasum line would leave those joints unguarded. + // YAML block scalars are dedented on parse, so the script's own lines carry no + // indentation here. + expect(archive!.run).toMatch(/^ *cd \.\.\/\.\.$/m); + const upload = release.jobs?.["package-standalone"]?.steps + ?.find(candidate => candidate.uses?.startsWith("actions/upload-artifact@")); + expect(String(upload?.with?.path)).toContain("dist/ocx-*.sha256"); + + const download = release.jobs?.["attach-release"]?.steps + ?.find(candidate => candidate.uses?.startsWith("actions/download-artifact@") + && candidate.with?.pattern === "standalone-*"); + expect(download?.with?.["merge-multiple"]).toBe(true); + expect(download?.with?.path).toBe("dist/release"); + + const verify = release.jobs?.["attach-release"]?.steps + ?.find(candidate => candidate.run?.includes("shasum")); + expect(verify).toBeDefined(); + expect(verify!.run).toContain("cd dist/release"); + expect(verify!.run).toContain("shasum -a 256 -c ./*.sha256"); + }); + + test("publication waits for both packaging jobs", () => { + const publish = release.jobs?.publish; + expect(publish).toBeDefined(); + expect(needsOf(publish).sort()) + .toEqual(["package-desktop", "package-standalone", "validate-dispatch"]); + + const attach = release.jobs?.["attach-release"]; + expect(attach).toBeDefined(); + expect(needsOf(attach).sort()).toEqual(["package-desktop", "package-standalone", "publish"]); + }); +}); + +describe("service lifecycle trigger coverage", () => { + const lifecycleText = readFileSync(repoPath(".github", "workflows", "service-lifecycle.yml"), "utf8"); + const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); + + test("both triggers cover the service directory and the desktop shell", () => { + const pushPaths = triggerPaths(lifecycleText, "push", "workflow_dispatch"); + const prPaths = triggerPaths(lifecycleText, "pull_request", "push"); + for (const paths of [prPaths, pushPaths]) { + expect(paths).toContain("src/service.ts"); + expect(paths).toContain("src/service/**"); + expect(paths).toContain("desktop/**"); + } + expect([...prPaths].sort()).toEqual([...pushPaths].sort()); + }); + + test("the release service gate matches every implemented service module and desktop file", () => { + const gateSource = releaseText.match(/grep -Eq '(\^\([^']+\)\$)'/)?.[1]; + expect(gateSource).toBeDefined(); + const gate = new RegExp(gateSource!); + + // Derived from the tree, not restated: the service implementation is a directory, so + // every module in it must satisfy the gate that demands lifecycle evidence. + const serviceModules = readdirSync(repoPath("src", "service")) + .filter(entry => entry.endsWith(".ts")); + expect(serviceModules.length).toBeGreaterThanOrEqual(10); + for (const module of serviceModules) { + expect(gate.test(`src/service/${module}`), `src/service/${module}`).toBe(true); + } + expect(gate.test("src/service.ts")).toBe(true); + + const desktopSurfaces = [ + ...readdirSync(repoPath("desktop", "scripts")).map(entry => `desktop/scripts/${entry}`), + ...readdirSync(repoPath("desktop", "src-tauri", "src")).map(entry => `desktop/src-tauri/src/${entry}`), + ]; + expect(desktopSurfaces.length).toBeGreaterThanOrEqual(10); + for (const path of desktopSurfaces) { + expect(gate.test(path), path).toBe(true); + } + + expect(gate.test("src/router.ts")).toBe(false); + expect(gate.test("docs-site/src/pages/index.astro")).toBe(false); + }); +}); diff --git a/tests/clients/desktop-proxy-direct-transport.test.ts b/tests/clients/desktop-proxy-direct-transport.test.ts new file mode 100644 index 00000000000..c4c76e2c50c --- /dev/null +++ b/tests/clients/desktop-proxy-direct-transport.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The desktop shell's management client talks to the loopback proxy with the admin token + * attached. reqwest resolves system proxy configuration by default — HTTP_PROXY, + * HTTPS_PROXY, and the platform proxy settings behind them — so a machine-wide proxy + * would receive a credential that is only ever meant for the loopback endpoint. The builder + * has to opt out explicitly; nothing in the request path re-checks it afterwards. + */ +describe("desktop management transport", () => { + const source = readFileSync(repoPath("desktop", "src-tauri", "src", "proxy.rs"), "utf8"); + + test("the proxy client disables system proxy resolution for its loopback traffic", () => { + // Line comments are stripped first: a .no_proxy() that only exists in prose must not + // satisfy the contract. + const code = source.replace(/\/\/[^\n]*/g, ""); + const builder = code.split("Client::builder()")[1]?.split(".build()")[0]; + expect(builder).toBeDefined(); + expect(builder).toContain(".no_proxy()"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 07135ba3e09..da49f05ab6c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1468,5 +1468,7 @@ "ambiguous-resend-gate.test.ts": "lib", "docs-developer-role-policy.test.ts": "ci-workflows", "ambiguous-resend-composition.test.ts": "lib", - "chat-inbound-developer-position.test.ts": "responses" + "chat-inbound-developer-position.test.ts": "responses", + "release-pipeline-contract.test.ts": "ci-workflows", + "desktop-proxy-direct-transport.test.ts": "clients" } diff --git a/tests/service/process-state.test.ts b/tests/service/process-state.test.ts index d785e295681..248317fd24a 100644 --- a/tests/service/process-state.test.ts +++ b/tests/service/process-state.test.ts @@ -96,6 +96,23 @@ describe("proxy process-state ownership", () => { expect(isOcxStartCommandLine("opencodex update --tag latest")).toBe(false); }); + test("recognizes the Windows standalone executable, whatever its case or path shape", () => { + // scripts/build-standalone.ts and desktop/scripts/prepare-sidecar.ts both emit ocx.exe + // on Windows targets, and the bundled sidecar is copied as ocx-.exe. A quoted + // install path with spaces is the realistic WMIC/PowerShell command line for it. + expect(isOcxCommandLine('"C:/Program Files/OpenCodex/bin/ocx.exe" start --port 10100')).toBe(true); + expect(isOcxCommandLine('"C:\\Program Files\\OpenCodex\\bin\\OCX.EXE" start')).toBe(true); + expect(isOcxCommandLine("C:/tools/ocx.exe stop")).toBe(true); + expect(isOcxCommandLine("ocx.exe")).toBe(true); + expect(isOcxCommandLine("opencodex.exe status")).toBe(true); + expect(isOcxStartCommandLine('"C:/Program Files/OpenCodex/bin/ocx.exe" start')).toBe(true); + // Lookalikes stay foreign: the token boundary around the executable name is the whole + // defence, and widening it for .exe must not widen it for neighbours. + expect(isOcxCommandLine("not-ocx.exe start")).toBe(false); + expect(isOcxCommandLine("myocx.exe")).toBe(false); + expect(isOcxCommandLine("ocx.exes start")).toBe(false); + }); + test("the ownership probe distinguishes a real owner from a reused PID", () => { // The stop-side teardown recovery asks this about a PID recorded in a receipt. Bare // liveness said "still running" for any process that inherited the number, so the From bcb2b92e6f3c8735ec24f0794c0d2caaf689f5da Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 11:59:35 +0900 Subject: [PATCH 63/87] feat(cli): expose ocx resolve and ocx stop --json for embedding shells (#5383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): expose ocx resolve and ocx stop --json for embedding shells D5/D4 of the app runtime ownership unit: the desktop shell stops resolving the config home, the port and liveness itself. ocx resolve [--json] exposes src/config/paths.ts (home), the CLI preferred-port selection (config.port ?? 10100), and the identity-checked findLiveProxy verdict (pid, runtime-versus-config provenance, version, role) as one versioned document (schema ocx-resolve/1). Exit 0 carries a not-found verdict; exit 1 means the caller must refuse to guess; arguments are pre-parsed in root.ts and exit 64 before preflight side effects, the same ordering ocx ready obeys. ocx stop --json is a reporting layer over the unchanged stop path: the receipt-backed teardown, drain, respawn verification and client-config restore run exactly as before, the human lines print on stderr, and stdout carries one ocx-stop/1 summary derived from the same booleans that pick the exit code (0/1/79/80 unchanged). * test(cli): pin the resolve and stop-summary contracts Driven red once before implementation (0 pass / 6 fail / 2 errors: missing modules, unknown command, missing stop JSON), then green after. Registered in both test-layout maps. The resolve subprocess tests use a closed configured port: findLiveProxy falls back to probing the configured port when no records exist, and a developer machine can have a real proxy answering the 10100 default — that is the liveness module working as designed, so the deterministic verdict case pins a dead port. * docs: document the resolve and stop --json shell contracts docs-site lifecycle reference gains both verbs; structure/runtime.md records why resolve owns no probing budget and why stop --json is a reporting layer over the unchanged stop path. * test(grok): follow handleStop structured return in the lifecycle oracle The wiring oracle pinned the boolean return shape; the stop --json summary returns { ok, summary } with ok preserving !stopFailed exactly. Assert the new shape and document why. * test(cli): satisfy the structured handleStop type in the effort fake The typed CliDispatchDeps literal predates the StopOutcome return; the stop runner is never invoked here, so reuse the real summarizer instead of restating the shape, and add the resolve handle. * fix(cli): tighten the resolve/stop contract after review Three review points folded in. The human stop branch keyed the downtime warning on a now-object return value, printing it even for failed stops; it reads .ok. resolve read config through loadConfig, which repairs an invalid config.json to factory defaults and would have handed the caller a guessed port; it now reads readConfigDiagnostics and exits 1 on a fallback source without probing, while a missing config remains defaults. Discovery borrows START_OWNERSHIP_LIVENESS, the start path ownership budget, because the verdict feeds the shell launch decision. And a failed shared teardown is recorded as failed, never restored. * test(cli): pin the refused-guess, ownership-budget, and failed-teardown cases Covers the invalid-config refusal (unit and subprocess), the ownership-safe probe budget (source oracle), the sharedTeardown failed mapping, and the human-mode failure path that must not print the downtime warning. Not run locally per the lane rule; red expectations are documented in the PR body. * docs: state the invalid-config refusal and the ownership probe budget * test(cli): restore process.exitCode with the suite convention A test batch shares one Bun process and Bun does not clear exitCode on a bare undefined assignment; the suite restores with ?? 0 (cli-dispatch.test.ts, service.test.ts). The module-level afterEach assigned undefined, so the last-set code leaked and failed the whole batch with zero failing tests. Save/restore per test instead. * test(cli): assert the stream split with emitting stubs CodeRabbit follow-up: the fake handleStop now emits a human line through console.log, so the JSON mode proves the stop output moved to stderr and the human mode proves it stayed on stdout. * fix(cli): tri-state resolve liveness and read-only resolve entry Re-audit follow-ups for lane A. Liveness is now three-valued: live, absent-proven (every recorded and configured endpoint definitively dead via the updater tri-state probe), or unknown — unknown exits 1 and never authorises a start, instead of collapsing into not-found after one weak probe. Discovery keeps the ownership-safe START_OWNERSHIP_LIVENESS budget. And resolve joins skipsCodexShimAutoRestore, so a read-only lookup made to populate a consent surface never triggers a shim repair first. The handleStop truthiness point from the re-audit was already fixed in 30cfe68ac7 (both stop branches read outcome.ok). * test(cli): match the endpoint-proof short-circuit everyEndpointProvenDown is Array.every: the first non-dead answer defeats the proof, so the unknown-runtime-endpoint case cannot also observe the configured port. Assert the designed contract instead: unknown on the record fails closed, and the all-dead case observes both endpoints in order. --- .../content/docs/reference/cli/lifecycle.md | 27 +- scripts/test-layout/layout.json | 3 + .../ocx/references/01_management_surface.md | 17 +- src/cli/capabilities.ts | 15 + src/cli/codex-shim-autorestore.ts | 3 + src/cli/dispatch.ts | 37 ++- src/cli/help.ts | 3 +- src/cli/index.ts | 49 +++- src/cli/registry.ts | 23 +- src/cli/resolve.ts | 229 +++++++++++++++ src/cli/root.ts | 25 +- src/cli/stop-report.ts | 143 ++++++++++ structure/runtime.md | 6 + tests/cli/cli-effort.test.ts | 12 +- tests/cli/cli-resolve-subprocess.test.ts | 125 +++++++++ tests/cli/cli-resolve.test.ts | 264 ++++++++++++++++++ tests/cli/cli-stop-json.test.ts | 258 +++++++++++++++++ .../codex-shim-autorestore.test.ts | 4 + tests/fixtures/test-layout-expected.json | 3 + tests/providers/xai/grok-lifecycle.test.ts | 4 +- .../update/update-stop-classification.test.ts | 6 +- 21 files changed, 1241 insertions(+), 15 deletions(-) create mode 100644 src/cli/resolve.ts create mode 100644 src/cli/stop-report.ts create mode 100644 tests/cli/cli-resolve-subprocess.test.ts create mode 100644 tests/cli/cli-resolve.test.ts create mode 100644 tests/cli/cli-stop-json.test.ts diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 07f6ca58537..1118d02985f 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -42,7 +42,7 @@ ocx start --port 10100 --socks5 ocx start --socks5-off ``` -### `ocx stop` +### `ocx stop [--json]` Stop the running proxy (by PID), remove the PID file, and restore native Codex. If a managed background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy. @@ -65,6 +65,14 @@ It does not enter the forced-stop fallback for a process already observed to hav receipt-backed deferral still leaves final restoration and receipt cleanup with the parent; failure to restore shared client configuration keeps the stop failed and its receipt outstanding. +`ocx stop --json` runs exactly the same stop path and prints one versioned summary document +(`schema: "ocx-stop/1"`) on stdout, while the human progress lines move to stderr. The summary +carries the outcome class (`stopped`, `not-running`, `history-incomplete`, `history-deferred`, +or `failed`), the service and proxy classifications, whether the runtime is down, and a stable +one-line message. Exit codes are identical with and without `--json`: 0 on success, 1 on failure, +79 when only Codex history cleanup did not complete, and 80 when the shared teardown was deferred +and is still owed. + ### `ocx restart` When a proxy is running, ask that exact attested PID and port to restart in place, wait for its @@ -239,6 +247,23 @@ The CLI's own `--json` output is deliberately narrower than the HTTP body: it em `unreachable`. Exit codes are 0 for ready; 1 for not-ready, pending, failed, timeout, or unreachable; and 64 for invalid arguments. +### `ocx resolve [--json]` + +Resolve the runtime facts a shell needs without re-implementing them: the config home, the +effective port, and the identity-checked liveness verdict. `--json` emits one versioned +document (`schema: "ocx-resolve/1"`) with `cliVersion`, `configHome`, `port` +(`effective`, `configured`, and `source`), and `liveness` (`status`, `pid`, `port`, +`source`, plus `version`, `role`, and `hostname` when the live proxy reports them). +Liveness has three answers: `live`, `absent-proven` (every recorded and configured endpoint +definitively refused or answered non-opencodex), and unknown — a timed-out probe or a listener +that withholds `/healthz` exits 1 rather than reading as absent, so only `absent-proven` may +authorise starting a new runtime. The port is the live listener's port when a proxy answers, +otherwise the configured port (default 10100). Exit 0 carries a trustworthy verdict; exit 1 means +the CLI could not resolve — including an invalid `config.json`, which is never repaired to +defaults here — and the caller must refuse to guess; any unknown argument exits 64. Discovery uses +the same ownership-safe probe budget as `ocx start`, because a false "nothing listening" answer +is how duplicate proxies happen. The verb is read-only and skips the shim auto-restore preflight. + ### `ocx doctor` The default report includes the native-write coordinator state and exact path using immutable diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c6aee6166be..73cea68be35 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -423,6 +423,8 @@ "cli-ready-subprocess.test.ts": "cli", "cli-ready.test.ts": "cli", "cli-registry.test.ts": "cli", + "cli-resolve-subprocess.test.ts": "cli", + "cli-resolve.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", "cli-start-auxiliary-bind.test.ts": "cli", @@ -430,6 +432,7 @@ "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", + "cli-stop-json.test.ts": "cli", "cli-storage-inspect.test.ts": "cli", "cli-transport-honesty.test.ts": "cli", "cli-usage-report.test.ts": "cli", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 4918176da31..5682c76ac71 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -72,6 +72,21 @@ JSON mode: `envelope`. - Reads /healthz plus local config; drives no management API route. +### `ocx resolve` + +One JSON document naming the config home, the effective port, and the identity-checked proxy liveness verdict. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the resolve document as JSON (the shell contract). | + +JSON mode: `envelope`. + +- Exit 0 carries a trustworthy verdict (live or proven absent); exit 1 means the CLI could not resolve and a caller must refuse to guess — unknown liveness never reads as absent. +- Built for embedding shells (desktop app): the liveness budgets stay owned by src/server/proxy-liveness.ts. + ### `ocx capabilities` List the declared CLI capabilities and the management routes they drive. @@ -915,6 +930,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 49 +- declared capabilities: 50 - of those, state-changing: 25 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index f651183d053..cb117bcba05 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -211,6 +211,21 @@ export const CAPABILITIES: readonly Capability[] = [ json: "envelope", details: ["Reads /healthz plus local config; drives no management API route."], }, + { + command: ["resolve"], + summary: "One JSON document naming the config home, the effective port, and the identity-checked proxy liveness verdict.", + // No management route, same split as status: discovery is the identity-checked + // /healthz probe inside findLiveProxy plus local config and the home from + // src/config/paths.ts. + routes: [], + flags: [{ name: "--json", value: "boolean", summary: "Emit the resolve document as JSON (the shell contract)." }], + mutates: false, + json: "envelope", + details: [ + "Exit 0 carries a trustworthy verdict (live or proven absent); exit 1 means the CLI could not resolve and a caller must refuse to guess — unknown liveness never reads as absent.", + "Built for embedding shells (desktop app): the liveness budgets stay owned by src/server/proxy-liveness.ts.", + ], + }, { command: ["hub", "invite"], summary: "Mint a single-use pairing code on a hub and print the exact `ocx connect` line for one more machine.", diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 87b59feefbb..a3419e665a1 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; + // `resolve` is read-only inspection for embedding shells: a lookup made to populate + // a consent surface must not trigger a shim repair side effect first. + if (command === "resolve") 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; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index beb3170d992..cea39a48d23 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -29,6 +29,8 @@ import { stripGrokConfig } from "../grok/inject"; import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } from "./restart-scope"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { isJsonOption, takeFlag, terminalSafeError } from "./runtime-api"; +import { printStopSummary, type StopOutcome } from "./stop-report"; +import type { ResolveArgs } from "./resolve"; import type { ClientConnectionState } from "../client/state"; import { OCX_NATIVE_REPLAY_RECOVERY_NOTE } from "../responses/compaction"; @@ -44,8 +46,9 @@ export interface CliDispatchDeps { /** Spawn a detached proxy child (stdio ignore, unref'd, provenance env). */ spawnDetached: (argv: readonly string[]) => void; handleStart: () => Promise; - handleStop: () => Promise; + handleStop: () => Promise; handleEnsure: (options?: { existingIsSuccess?: boolean }) => Promise; + handleResolve: (args: ResolveArgs) => Promise; handleTrayProxyStart: (existingIsSuccess?: boolean) => Promise; handleTrayProxyRestart: () => Promise; handleRestartStartWhenStopped: () => Promise; @@ -98,11 +101,39 @@ const commandRunners: Record = { stop: async deps => { // Downtime warning lives HERE, not in handleStop: `restart`/tray-restart callers // re-start the proxy immediately, so warning there would contradict the next line. - if (await deps.handleStop()) { - console.log("⚠️ Codex/Claude requests through the proxy will fail until it is restarted ('ocx start' or 'ocx service start')."); + const warning = "⚠️ Codex/Claude requests through the proxy will fail until it is restarted ('ocx start' or 'ocx service start')."; + if (!takeFlag(deps.args.slice(1), "--json")) { + // handleStop returns the structured outcome now; an object is always truthy, so + // the warning must key on .ok — otherwise a failed stop would still claim downtime. + if ((await deps.handleStop()).ok) console.log(warning); + return Number(process.exitCode ?? 0); } + // --json is a reporting layer over the SAME stop path: the receipt, the drain, the + // respawn verification and the client-config restore run unchanged. Human output + // still prints, but on stderr, so stdout carries exactly one JSON summary document. + // The exit code (0/1/79/80) crosses the process boundary untouched — the shell reads + // it from the child, and the stop-contract codes must survive the JSON mode. + const humanLog = console.log; + console.log = console.error; + let outcome: StopOutcome | undefined; + try { + outcome = await deps.handleStop(); + if (outcome.ok) console.log(warning); + } finally { + console.log = humanLog; + } + // A throw above propagates after the finally restores the console, so reaching here + // with an undefined outcome cannot happen; the guard keeps the assignment provable. + if (outcome) printStopSummary(outcome.summary); return Number(process.exitCode ?? 0); }, + resolve: async deps => { + // Same fail-closed shape as `ready`: parseCliHead pre-parsed the verb before any + // preflight side effect, so a missing resolveArgs means dispatch diverged. Refuse + // with code 64 and perform NO I/O. + if (!deps.head.resolveArgs) return 64; + return await deps.handleResolve(deps.head.resolveArgs); + }, restore: async deps => { const restoreArgs = deps.args.slice(1); const restoreJson = takeFlag(restoreArgs, "--json"); diff --git a/src/cli/help.ts b/src/cli/help.ts index 67003ebca65..b9b7a6e2e1b 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -23,7 +23,7 @@ Usage: ocx setup Interactive setup (alias: init) ocx start [--port ] [--socks5 [host:port] | --socks5-off] Start the proxy; SOCKS5 defaults to 127.0.0.1:10808 - ocx stop Stop the proxy AND restore native Codex (plain codex works again) + ocx stop [--json] Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) ocx restore back Re-point codex at the running proxy (undo restore) ocx restore --remove-codex-provider-table @@ -63,6 +63,7 @@ Usage: ocx health [--json] Check proxy health (exit 0=healthy, 1=not) ocx capabilities [--json] List declared capabilities and the API routes they drive ocx ready [--json] [--wait [--timeout ]] Check post-sync readiness (exit 0 only when ready) + ocx resolve [--json] Config home, effective port, and liveness (JSON for shells) ocx provider Providers, connectivity, quota, and selected models ocx account Accounts, login/reauth, key pools, and quota controls ocx models Live/custom models, visibility, context, and shadow calls diff --git a/src/cli/index.ts b/src/cli/index.ts index ff48ca71f02..b9fe221eb51 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -89,6 +89,8 @@ import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, import { createReadinessGate } from "../server/readiness"; import { isApiAuthRequired } from "../server/auth-cors"; import { runReady, type ReadyArgs } from "./ready"; +import { runResolve, type ResolveArgs } from "./resolve"; +import { summarizeStopRun, type StopOutcome, type StopRunRecord } from "./stop-report"; import { runCli } from "./root"; import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control"; import { startupDataPlaneToken } from "../lib/service-secrets"; @@ -980,6 +982,16 @@ async function handleStop() { // service — the exact failure this flag prevents. A plain stop failure is different: we // tried, so local teardown still proceeds. let ownershipBlocked = false; + // Structured twin of the human lines below, for `ocx stop --json`: one document the + // desktop shell can read across the process boundary (D4). Every field is assigned + // where the corresponding boolean already flips — the summarizer never re-decides. + const record: StopRunRecord = { + service: "absent", + proxy: "unknown", + sharedTeardown: "skipped", + inheritedTeardownBlocks: false, + receiptClearFailed: false, + }; // Deferring shared teardown to this process is an obligation, so record it on disk // before asking for it (#3008). A parent that dies mid-stop would otherwise leave the // client config routed at a proxy that is already gone, with nothing to find later. @@ -1057,6 +1069,7 @@ async function handleStop() { }; try { const serviceStop = stopServiceIfInstalledDetailed(); + record.service = serviceStop; stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable"; schedulerCanRespawn = serviceStop === "stopped-respawnable"; // No "won't respawn" claim here: a stopped Task Scheduler can still respawn through @@ -1077,6 +1090,7 @@ async function handleStop() { console.error(" Run 'ocx service status' to see the query error, repair Task Scheduler access, then retry."); } } catch (err) { + record.service = "error"; if (isServiceOwnershipError(err)) { ownershipBlocked = true; stopFailed = true; @@ -1100,8 +1114,10 @@ async function handleStop() { console.log(`✅ Proxy (PID ${pid}) stopped.`); removePid(pid); removeRuntimePort(pid); + record.proxy = "stopped"; } catch (err) { stopFailed = true; + record.proxy = err instanceof ProxyOwnershipRefusedError ? "ownership-refused" : "stop-failed"; console.error(`❌ Failed to stop proxy (PID ${pid}).`); // stopProxy throws with the reason — an ownership refusal (409) carries the // remediation ("run the stop from that home"). Swallowing it leaves the operator @@ -1136,8 +1152,10 @@ async function handleStop() { { hostname: live.hostname ?? "127.0.0.1", port: live.port }, ); console.log(`✅ Proxy (PID ${live.pid}) stopped.`); + record.proxy = "stopped-orphan"; } catch (err) { stopFailed = true; + record.proxy = err instanceof ProxyOwnershipRefusedError ? "ownership-refused" : "stop-failed"; console.error(`❌ Failed to stop proxy (PID ${live.pid}).`); const detail = err instanceof Error ? err.message : String(err); if (detail) console.error(` ${detail}`); @@ -1155,12 +1173,14 @@ async function handleStop() { // under a proxy that is still serving — the exact failure the deferral exists to // prevent, arrived at from the other direction. stopFailed = true; + record.proxy = "unresolvable-pid"; ownershipBlocked = true; console.error(`❌ A proxy is answering on port ${live.port}, but no process id could be resolved for it, so it cannot be stopped from here.`); console.error(" Skipping shared teardown: restoring client config while it serves would leave both pointing at each other."); console.error(" Stop it from the home that started it, or end the process manually, then rerun 'ocx stop'."); - } else if (!stoppedService) { - console.log("No running proxy found."); + } else { + record.proxy = "not-running"; + if (!stoppedService) console.log("No running proxy found."); } if (!stopFailed) { // `readPid() === null` means the snapshotted pid file was absent, invalid, dead, or @@ -1183,6 +1203,7 @@ async function handleStop() { const survivor = await proxyStillLiveAfterStop({ canRespawn: true }); if (survivor) { stopFailed = true; + record.proxy = "respawned"; console.error(`❌ A proxy is still listening on port ${survivor.port} after the service stop; it is being respawned.`); console.error(" Skipping shared teardown: restoring client config while the proxy runs leaves both pointing at each other."); ownershipBlocked = true; @@ -1218,6 +1239,7 @@ async function handleStop() { // No file, no nonce: nothing to quarantine and nothing to remove. The home itself // may be hiding an obligation, so block and ask for the directory to be fixed. inheritedBlocks = true; + record.inheritedTeardownBlocks = true; stopFailed = true; console.error(`❌ ${read.detail}, so this stop cannot tell whether a shared teardown is still owed.`); console.error(" Skipping shared teardown. Fix access to the opencodex home, then rerun 'ocx stop'."); @@ -1226,6 +1248,7 @@ async function handleStop() { if (read.state === "invalid") { unreadable.push(read); inheritedBlocks = true; + record.inheritedTeardownBlocks = true; stopFailed = true; console.error(`❌ A pending-teardown receipt could not be read (${read.detail}).`); console.error(" It names no endpoint, so this stop cannot prove the proxy it belonged to is down."); @@ -1237,6 +1260,7 @@ async function handleStop() { // proxy on an explicit --port can be respawned there while this address refuses, // so "dead" here proves nothing and must not authorize a restore. inheritedBlocks = true; + record.inheritedTeardownBlocks = true; stopFailed = true; console.error("❌ A shared teardown from an earlier stop is outstanding, but that stop could not record the address it was stopping."); console.error(` Only the configured address (${read.receipt.endpoint.hostname}:${read.receipt.endpoint.port}) was recorded, which cannot prove the right proxy is down.`); @@ -1248,12 +1272,14 @@ async function handleStop() { continue; } inheritedBlocks = true; + record.inheritedTeardownBlocks = true; stopFailed = true; console.error(`❌ A shared teardown from an earlier stop is still outstanding, and the proxy on ${read.receipt.endpoint.hostname}:${read.receipt.endpoint.port} could not be confirmed down.`); console.error(" Skipping shared teardown: restoring client config under a proxy that may still be running is what the deferral exists to prevent."); console.error(" The obligation is preserved; retry once the proxy is confirmed stopped."); } } + if (nativeRestoreHandledByProxy) record.sharedTeardown = "performed-by-proxy"; const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy; if (!restoreBlocked) { if (recoveredNonces.length > 0) { @@ -1262,6 +1288,7 @@ async function handleStop() { console.log("↩️ Finishing a shared teardown left unfinished by an earlier stop."); } const restore = await restoreSharedClientStateAfterStop(); + record.sharedTeardown = restore.historyDeferred ? "refused" : restore.other ? "failed" : "restored"; if (restore.other) stopFailed = true; else if (restore.historyDeferred) historyDeferredNonces = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces; else if (restore.historyOnly) historyOnlyFailure = true; @@ -1287,6 +1314,7 @@ async function handleStop() { // removal is surfaced rather than swallowed. if (!clearPendingTeardown(nonce)) { stopFailed = true; + record.receiptClearFailed = true; console.error(`❌ The shared teardown finished, but its receipt could not be removed: ${pendingTeardownPathFor(nonce)}`); console.error(" Remove it manually; otherwise every later stop and update will try to recover it again."); } @@ -1331,7 +1359,13 @@ async function handleStop() { ? STOP_HISTORY_DEFERRED_EXIT_CODE : 1; } - return !stopFailed; + const summary = summarizeStopRun(record, { + failed: stopFailed, + historyOnly: historyOnlyFailure, + historyDeferred: historyDeferredNonces !== null, + exitCode: Number(process.exitCode ?? 0), + }); + return { ok: !stopFailed, summary }; } async function handleUninstall() { @@ -1761,6 +1795,14 @@ async function handleReady(args: ReadyArgs): Promise { return runReady(args); } +/** + * `ocx resolve` — argument parsing already happened in src/cli/root.ts (before any + * preflight side effect), so this handler only runs the runner and returns its exit code. + */ +async function handleResolve(args: ResolveArgs): Promise { + return runResolve(args); +} + process.exit(await dispatchCommand(head, { args, command, @@ -1781,6 +1823,7 @@ process.exit(await dispatchCommand(head, { }, handleStart, handleStop, + handleResolve, handleEnsure, handleTrayProxyStart, handleTrayProxyRestart, diff --git a/src/cli/registry.ts b/src/cli/registry.ts index e19788efe35..6e4756c1664 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -29,7 +29,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "--socks5-off Clear a saved SOCKS5 outbound proxy from config.proxy.", ], }, - { name: "stop", usage: "ocx stop", summary: "Stop the proxy and restore native Codex config." }, + { + name: "stop", + usage: "ocx stop [--json]", + summary: "Stop the proxy and restore native Codex config.", + details: [ + "--json keeps the stop path unchanged and prints one structured summary document on stdout; human output moves to stderr.", + "Exit codes are identical with and without --json: 0, 1, 79 (history cleanup incomplete), 80 (teardown deferred).", + ], + }, { name: "restore", aliases: ["eject"], @@ -549,6 +557,19 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Invalid or unknown arguments exit 64. Not-ready, pending, failed, timeout, and unreachable exit 1.", ], }, + { + name: "resolve", + usage: "ocx resolve [--json]", + summary: "Emit the resolved config home, effective port, and identity-checked proxy liveness as one JSON document.", + details: [ + "Machine surface for embedding shells: it replaces a second home/port/liveness implementation beside the CLI.", + "The port is the live listener's port when an opencodex proxy answers, otherwise the configured port (default 10100).", + "Liveness is three-valued: live, absent-proven (every recorded and configured endpoint definitively dead), or unknown — unknown exits 1 and never reads as absent.", + "--json emits one versioned document (schema ocx-resolve/1); the default prints two human lines.", + "Exit 0 carries a trustworthy verdict; exit 1 means the CLI could not resolve (invalid config or undecidable liveness) and callers must refuse to guess.", + "Any unknown argument exits 64 before preflight side effects.", + ], + }, { name: "lab", usage: "ocx lab [options] [--json]", diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts new file mode 100644 index 00000000000..a4dcc851645 --- /dev/null +++ b/src/cli/resolve.ts @@ -0,0 +1,229 @@ +/** + * `ocx resolve` — the machine-readable runtime resolution surface for an embedding shell. + * + * D5 of devlog/_plan/260921_app_runtime_ownership/: the desktop shell must stop resolving + * the config home, the port and liveness itself. The tuned probe budgets in + * src/server/proxy-liveness.ts exist because a shell-side reimplementation answered + * "nobody listening" twice and started duplicate proxies; this verb exposes that module's + * verdict instead of copying it, alongside src/config/paths.ts (the home) and the CLI's + * own preferred-port selection (`config.port ?? 10100` — resolve takes no --port). + * + * Contract: + * - `--json` puts exactly ONE JSON document on stdout, versioned by `schema`; the + * default prints two human lines, the same opt-in split as `ocx ready --json`. + * - liveness has three answers, not two: "live", "absent-proven" (every recorded and + * configured endpoint definitively refused or answered non-opencodex), and unknown. + * Unknown NEVER reaches the wire as absent — a probe that timed out, a listener that + * withheld /healthz, or an identity mismatch exits 1 instead. Only "absent-proven" + * may authorise starting a new runtime. + * - exit 0 whenever a trustworthy verdict exists — live, or proven absent. A MISSING + * config.json is defaults, not an error. + * - exit 1 when the CLI cannot resolve: an invalid config.json must NOT be answered + * with `loadConfig`'s repair-to-defaults behaviour, because that hands the caller + * a guessed port; and unknown liveness must not be answered as absence. + * - exit 64 for any argument, pre-parsed in src/cli/root.ts before preflight side + * effects, mirroring `ocx ready`. The verb is read-only and listed in + * skipsCodexShimAutoRestore, so a lookup made to populate a consent surface never + * triggers a shim repair side effect. + * + * Discovery uses the START_OWNERSHIP_LIVENESS budget, not the 750ms single-shot default: + * the shell's launch decision keys on this verdict, and answering "nobody" for a slow + * live proxy is the duplicate-proxy decision the start path tunes against (#5004). + * + * Lives outside cli/index.ts (which dispatches argv at module top level) so tests can + * import it, the same split as ready.ts. + */ +import { readConfigDiagnostics, type ConfigDiagnostics } from "../config"; +import { getConfigDir } from "../config/paths"; +import { readRuntimePort } from "../config/process-state"; +import { packageVersion } from "../lib/package-version"; +import { findLiveProxy, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; +import { endpointsToProve, everyEndpointProvenDown, type ProbeEndpoint } from "./uninstall-plan"; +import { probeProxyLiveness } from "../update/proxy-liveness-probe.mjs"; + +/** Wire version of the resolve document. Bump only on an incompatible shape change. */ +export const RESOLVE_SCHEMA = "ocx-resolve/1"; + +/** The port every preferred-port selection in the CLI falls back to. */ +export const RESOLVE_DEFAULT_PORT = 10100; + +export interface ResolveLivenessJson { + /** + * "live" when the identity-checked probe found our proxy; "absent-proven" when every + * recorded and configured endpoint is definitively dead. The third state — unknown — + * exits 1 before this document is printed, so it never appears on the wire as absence. + */ + status: "live" | "absent-proven"; + pid: number | null; + port: number | null; + /** Raw bind hostname that answered; compose probe URLs via probeHostname semantics. */ + hostname?: string; + /** Where the verdict came from: the runtime record, or the configured listen port. */ + source: LiveProxy["source"] | null; + /** Version the live proxy reported on /healthz, when it reported one. */ + version?: string; + /** Listener role the live proxy reported, when it reported one ("client" = connected client). */ + role?: string; +} + +export interface ResolveJson { + schema: typeof RESOLVE_SCHEMA; + /** Version of this CLI binary, so a shell can compare its engine against the live proxy. */ + cliVersion: string; + /** Resolved opencodex home (OPENCODEX_HOME or ~/.opencodex), from src/config/paths.ts. */ + configHome: string; + port: { + /** The port a client should use: the live listener's port when one answers, else the configured one. */ + effective: number; + /** The configured listen port (config.port ?? 10100); what a start would prefer. */ + configured: number; + /** Whether `effective` came from a live proxy or from configuration. */ + source: LiveProxy["source"]; + }; + liveness: ResolveLivenessJson; +} + +export interface ResolveArgs { + json: boolean; +} + +export type ResolveParseResult = { ok: true; args: ResolveArgs } | { ok: false; code: 64 }; + +/** Pure argument parser: the only flag is `--json`. */ +export function parseResolveArgs(argv: string[]): ResolveParseResult { + for (const flag of argv) { + if (flag !== "--json") return { ok: false, code: 64 }; + } + return { ok: true, args: { json: argv.includes("--json") } }; +} + +export interface ResolveIo { + configDir?: () => string; + readDiagnostics?: () => ConfigDiagnostics; + findLive?: () => Promise; + /** Runtime-port record reader; production default is readRuntimePort. */ + readRuntime?: () => { port?: number; hostname?: string } | null; + /** Tri-state endpoint probe; production default is the updater's probeProxyLiveness. */ + probeEndpoint?: (endpoint: ProbeEndpoint) => "live" | "dead" | "unknown"; + cliVersion?: () => string; + stdout?: { log: (s: string) => void }; + stderr?: { error: (s: string) => void }; +} + +function livenessJson(live: LiveProxy | null): ResolveLivenessJson { + // Reaching here with null means absence was PROVEN by the caller (unknown exits 1 + // before this document is built). + if (!live) return { status: "absent-proven", pid: null, port: null, source: null }; + return { + status: "live", + pid: live.pid, + port: live.port, + source: live.source, + ...(live.hostname === undefined ? {} : { hostname: live.hostname }), + ...(live.version === undefined ? {} : { version: live.version }), + ...(live.role === undefined ? {} : { role: live.role }), + }; +} + +/** Pure shaper: one live verdict plus configuration becomes the wire document. */ +export function buildResolveJson( + config: { port?: number }, + live: LiveProxy | null, + configHome: string, + cliVersion: string, +): ResolveJson { + const configured = config.port ?? RESOLVE_DEFAULT_PORT; + return { + schema: RESOLVE_SCHEMA, + cliVersion, + configHome, + port: { + effective: live ? live.port : configured, + configured, + source: live ? live.source : "config", + }, + liveness: livenessJson(live), + }; +} + +/** + * Human form: two lines, no prose flourish — an operator skims it, a shell uses --json. + */ +function reportHuman(json: ResolveJson, stdout: { log: (s: string) => void }): void { + stdout.log(`Config home: ${json.configHome}`); + const live = json.liveness; + if (live.status === "live") { + const pidText = live.pid === null ? "unknown" : String(live.pid); + const versionText = live.version ?? "unknown version"; + stdout.log(`Proxy live on port ${json.port.effective} (PID ${pidText}, ${versionText}); effective port ${json.port.effective}.`); + } else { + stdout.log(`No live proxy (absence proven); effective port ${json.port.effective} (configured).`); + } +} + +/** + * Run `ocx resolve` over injected I/O. Returns the exit code. The production defaults + * read config through the diagnostics surface (which distinguishes missing, valid and + * invalid instead of repairing to defaults) and perform one identity-checked discovery + * at the ownership-safe budget — resolve adds no probing policy of its own. + */ +export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise { + const stdout = io.stdout ?? console; + const stderr = io.stderr ?? console; + const configDir = io.configDir ?? getConfigDir; + const readDiagnostics = io.readDiagnostics ?? readConfigDiagnostics; + const findLive = io.findLive ?? (() => findLiveProxy(START_OWNERSHIP_LIVENESS)); + const readRuntime = io.readRuntime ?? readRuntimePort; + // The updater's tri-state probe takes (port, hostname) and is plain .mjs (untyped); + // adapt it to the endpoint-shaped seam here. Its own return vocabulary is the + // closed "live" | "dead" | "unknown" set. + const probeEndpoint = io.probeEndpoint + ?? ((endpoint: ProbeEndpoint) => probeProxyLiveness(endpoint.port, endpoint.hostname) as "live" | "dead" | "unknown"); + const cliVersion = io.cliVersion ?? packageVersion; + const configHome = configDir(); + let diagnostics: ConfigDiagnostics; + try { + diagnostics = readDiagnostics(); + } catch (error) { + // A resolution that could not run must not read as "no proxy": the caller has to + // refuse to guess (D5) rather than treat this as a proven-absent verdict. + stderr.error(`resolve failed: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + if (diagnostics.source === "fallback") { + // An invalid config must not resolve to defaults: the effective port would be a + // guess at 10100 while the operator's config.port is unread. The repair-to-defaults + // policy in loadConfig is for interactive recovery, not for a shell contract. + stderr.error(`resolve failed: the config in ${configHome} is invalid (${diagnostics.error ?? "unknown error"}); refusing to guess.`); + return 1; + } + let live: LiveProxy | null; + try { + live = await findLive(); + } catch (error) { + stderr.error(`resolve failed: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + if (!live) { + // findLiveProxy collapses "definitely nothing" and "could not tell" into the same + // null. The launch decision keys on this verdict, so resolve owes the caller the + // tri-state answer the updater already enforces: only EVERY candidate definitively + // dead is absence. Anything else is unknown, and unknown exits 1 — it must never + // authorise starting a second runtime. + let provenDown = false; + try { + provenDown = everyEndpointProvenDown(endpointsToProve(readRuntime(), diagnostics.config), probeEndpoint); + } catch { + // A probe that cannot run is not evidence of absence. + provenDown = false; + } + if (!provenDown) { + stderr.error("resolve: liveness is unknown (a probe timed out or a listener withheld /healthz); refusing to treat unknown as absent."); + return 1; + } + } + const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion()); + if (args.json) stdout.log(JSON.stringify(json)); + else reportHuman(json, stdout); + return 0; +} diff --git a/src/cli/root.ts b/src/cli/root.ts index 65eaf21cda0..9ff8d63353c 100644 --- a/src/cli/root.ts +++ b/src/cli/root.ts @@ -10,16 +10,19 @@ */ import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help"; import { parseReadyArgs, type ReadyArgs } from "./ready"; +import { parseResolveArgs, type ResolveArgs } from "./resolve"; import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore"; export interface CliHead { - kind: "version" | "help" | "ready" | "command"; + kind: "version" | "help" | "ready" | "resolve" | "command"; command: string | undefined; args: string[]; /** For kind "help": the subcommand whose usage should print, if any. */ helpTarget?: string; /** Present only for `ready`; undefined when the ready args failed to parse. */ readyArgs?: ReadyArgs; + /** Present only for `resolve`; undefined when the resolve args failed to parse. */ + resolveArgs?: ResolveArgs; } export function parseCliHead(argv: string[]): CliHead { @@ -51,6 +54,13 @@ export function parseCliHead(argv: string[]): CliHead { if (!parsed.ok) return { kind: "ready", command, args, readyArgs: undefined }; return { kind: "ready", command, args, readyArgs: parsed.args }; } + // Same ordering contract as `ready`: `ocx resolve` rejects any argument with exit + // 64 BEFORE maybeAutoRestoreCodexShim (or any other preflight with side effects) runs. + if (command === "resolve") { + const parsed = parseResolveArgs(args.slice(1)); + if (!parsed.ok) return { kind: "resolve", command, args, resolveArgs: undefined }; + return { kind: "resolve", command, args, resolveArgs: parsed.args }; + } return { kind: "command", command, args }; } @@ -79,6 +89,19 @@ export async function runCli(argv: string[]): Promise { maybeAutoRestoreCodexShim(head.command, head.args); return head; } + case "resolve": { + // Fail-closed impossible-state guard, mirroring ready: the pre-parse above already + // rejected invalid arguments before any preflight, so a missing resolveArgs means + // dispatch diverged. Refuse with code 64 and perform NO I/O. + if (!head.resolveArgs) { + console.error("Usage: ocx resolve [--json]"); + console.error(" --json prints one JSON document: the config home, the effective port,"); + console.error(" and the identity-checked liveness verdict."); + process.exit(64); + } + maybeAutoRestoreCodexShim(head.command, head.args); + return head; + } case "command": maybeAutoRestoreCodexShim(head.command, head.args); return head; diff --git a/src/cli/stop-report.ts b/src/cli/stop-report.ts new file mode 100644 index 00000000000..63d192f6f6c --- /dev/null +++ b/src/cli/stop-report.ts @@ -0,0 +1,143 @@ +/** + * Structured summary for `ocx stop --json` (D4 of the app runtime ownership unit). + * + * The desktop shell drives the real `ocx stop` as a child process so the receipt-backed + * teardown, the drain, the Windows respawn verification and the client-config restore run + * exactly as they do from a terminal. This module makes that run's RESULT readable from + * outside: a StopRunRecord is threaded through the existing stop path in + * src/cli/index.ts (the same booleans that already decide the exit code), and + * summarizeStopRun turns it into one versioned JSON document. + * + * Nothing here re-decides anything. If a field of the record is wrong, the fix belongs + * in the stop path, not in the summarizer. + */ +import { STOP_HISTORY_DEFERRED_EXIT_CODE } from "../update/stop-contract.mjs"; + +/** Wire version of the stop summary document. */ +export const STOP_SUMMARY_SCHEMA = "ocx-stop/1"; + +export type StopServiceOutcome = + | "absent" + | "stopped" + | "stopped-respawnable" + | "failed" + | "state-unknown" + | "error"; + +export type StopProxyOutcome = + | "stopped" + | "stopped-orphan" + | "not-running" + | "stop-failed" + | "ownership-refused" + | "unresolvable-pid" + | "respawned" + | "unknown"; + +export type StopSharedTeardownOutcome = + | "restored" + | "performed-by-proxy" + | "refused" + | "failed" + | "skipped"; + +/** Facts recorded where the stop path already decides them. */ +export interface StopRunRecord { + /** What stopServiceIfInstalledDetailed returned, or "error" when it threw. */ + service: StopServiceOutcome; + /** Which proxy path ran and how it ended. */ + proxy: StopProxyOutcome; + /** Who ended up restoring shared client config (native Codex + Grok). */ + sharedTeardown: StopSharedTeardownOutcome; + /** An inherited pending-teardown receipt blocked the restore. */ + inheritedTeardownBlocks: boolean; + /** A discharged receipt could not be removed from disk. */ + receiptClearFailed: boolean; +} + +/** The internal booleans that already pick the process exit code. */ +export interface StopRunSignals { + failed: boolean; + historyOnly: boolean; + historyDeferred: boolean; + exitCode: number; +} + +export interface StopSummaryJson { + schema: typeof STOP_SUMMARY_SCHEMA; + /** Strict exit-code view: true only for exit 0. */ + ok: boolean; + outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed"; + exitCode: number; + /** True when this stop left no proxy of this home running by its own paths. */ + runtimeDown: boolean; + service: StopServiceOutcome; + proxy: StopProxyOutcome; + sharedTeardown: StopSharedTeardownOutcome; + /** One stable human-readable line for a caller's UI. */ + message: string; +} + +/** What handleStop returns: the pre-existing boolean plus the structured twin. */ +export interface StopOutcome { + /** + * The exact boolean the stop path returned before summaries existed (!stopFailed). + * It is NOT the same as summary.ok: 79/80 stops return true here, because the + * downtime warning that keys on it applies whenever the runtime went down. + */ + ok: boolean; + summary: StopSummaryJson; +} + +function stopOutcome(record: StopRunRecord, signals: StopRunSignals): StopSummaryJson["outcome"] { + if (signals.failed) return "failed"; + if (signals.historyOnly) return "history-incomplete"; + if (signals.historyDeferred) { + // The deferred exit code is a proven claim; anything else means another obligation + // was sitting in the home, which the exit-code logic already reports as failure. + return signals.exitCode === STOP_HISTORY_DEFERRED_EXIT_CODE ? "history-deferred" : "failed"; + } + if (record.proxy === "not-running") return "not-running"; + if (record.proxy === "stopped" || record.proxy === "stopped-orphan") return "stopped"; + return "failed"; +} + +function stopMessage(record: StopRunRecord, signals: StopRunSignals, outcome: StopSummaryJson["outcome"]): string { + if (record.proxy === "respawned") return "The proxy was respawned after the stop; it is still running."; + if (record.proxy === "ownership-refused") return "The proxy refused the stop; it belongs to a different opencodex home."; + if (record.proxy === "unresolvable-pid") return "A proxy is answering, but its process id could not be resolved, so it was not stopped."; + if (record.proxy === "stop-failed") return "The proxy process could not be stopped."; + if (record.service === "failed") return "The installed service manager did not stop and may respawn the proxy."; + if (record.service === "state-unknown") return "The service manager state could not be read."; + if (record.service === "error") return "Stopping the installed service failed."; + if (record.inheritedTeardownBlocks) return "An earlier stop left an outstanding shared teardown that could not be confirmed."; + if (record.sharedTeardown === "failed") return "The shared teardown failed; client configuration may still point at the stopped proxy."; + if (record.receiptClearFailed) return "The shared teardown finished, but its receipt could not be removed."; + if (record.sharedTeardown === "refused") return "The shared teardown was refused before it changed anything; it is still owed."; + if (signals.historyOnly) return "The proxy stopped; Codex history cleanup did not complete."; + if (outcome === "history-deferred") return "The proxy stopped; the shared teardown was deferred and is still owed."; + if (outcome === "not-running") return "No proxy was running."; + if (outcome === "stopped") return "The proxy stopped."; + return "The stop failed."; +} + +/** Pure mapper from the recorded run to the wire document. */ +export function summarizeStopRun(record: StopRunRecord, signals: StopRunSignals): StopSummaryJson { + const outcome = stopOutcome(record, signals); + return { + schema: STOP_SUMMARY_SCHEMA, + ok: signals.exitCode === 0, + outcome, + exitCode: signals.exitCode, + runtimeDown: record.proxy === "stopped" || record.proxy === "stopped-orphan" || record.proxy === "not-running", + service: record.service, + proxy: record.proxy, + sharedTeardown: record.sharedTeardown, + message: stopMessage(record, signals, outcome), + }; +} + +/** Emit the summary as exactly one JSON document on stdout. */ +export function printStopSummary(summary: StopSummaryJson, stdout: { log: (s: string) => void } = console): void { + stdout.log(JSON.stringify(summary)); +} diff --git a/structure/runtime.md b/structure/runtime.md index 80a82701a9b..64172878654 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -50,6 +50,12 @@ this wire projection does not change the usage ledger. Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. +## CLI resolve and stop contracts for embedding shells + +`ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. + +`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80 cross the process boundary unchanged. + ## Native main reauth JSON output `src/cli/account-main.ts` emits one JSON object to stdout when `ocx account main reauth --device --no-wait --json` succeeds. The human-readable `follow up:` line is emitted only without `--json`; `flowId` remains available for status polling. `tests/cli/cli-native-profile.test.ts` parses the complete captured stdout and preserves coverage of the human follow-up. diff --git a/tests/cli/cli-effort.test.ts b/tests/cli/cli-effort.test.ts index b80028fd027..c846442c9ac 100644 --- a/tests/cli/cli-effort.test.ts +++ b/tests/cli/cli-effort.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { handleEffortCommand } from "../../src/cli/effort"; import { dispatchCommand } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; +import { summarizeStopRun } from "../../src/cli/stop-report"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import type { OcxConfig } from "../../src/types"; @@ -77,8 +78,17 @@ function fakeDeps(args: string[] = []): { startArgv: () => [], spawnDetached: () => {}, handleStart: async () => {}, - handleStop: async () => true, + // The effort runner never reaches stop; the fake only has to satisfy the typed + // interface, so reuse the real summarizer rather than restating the shape. + handleStop: async () => ({ + ok: true, + summary: summarizeStopRun( + { service: "absent", proxy: "not-running", sharedTeardown: "skipped", inheritedTeardownBlocks: false, receiptClearFailed: false }, + { failed: false, historyOnly: false, historyDeferred: false, exitCode: 0 }, + ), + }), handleEnsure: async () => true, + handleResolve: async () => 0, handleTrayProxyStart: async () => true, handleTrayProxyRestart: async () => {}, handleRestartStartWhenStopped: async () => true, diff --git a/tests/cli/cli-resolve-subprocess.test.ts b/tests/cli/cli-resolve-subprocess.test.ts new file mode 100644 index 00000000000..0015daeb079 --- /dev/null +++ b/tests/cli/cli-resolve-subprocess.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); +const cliPath = join(repoRoot, "src", "cli", "index.ts"); + +interface ResolveRun { + exitCode: number; + stdout: string; + stderr: string; +} + +async function runResolveCli(args: string[], home: string): Promise { + const child = Bun.spawn([process.execPath, cliPath, ...args], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: home }, + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => child.kill(), 15_000); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; + } finally { + clearTimeout(timer); + } +} + +describe("ocx resolve real subprocess", () => { + test("a home whose configured port is closed resolves with a proven-absent verdict", async () => { + // Port 9 is the suite's conventional dead port (see cli-help.test.ts): the liveness + // fallback probes the configured port when no records exist, and on a developer + // machine a real proxy can answer the 10100 default — that is findLiveProxy working + // as designed, so the deterministic verdict case pins a closed port instead. + const home = mkdtempSync(join(tmpdir(), "ocx-resolve-closed-port-")); + try { + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 9 }), "utf8"); + const run = await runResolveCli(["resolve", "--json"], home); + expect(run.exitCode).toBe(0); + const parsed = JSON.parse(run.stdout) as { + schema: string; + configHome: string; + port: { effective: number; configured: number; source: string }; + liveness: { status: string; pid: null; port: null; source: null }; + }; + expect(parsed.schema).toBe("ocx-resolve/1"); + expect(parsed.configHome).toBe(home); + expect(parsed.port).toEqual({ effective: 9, configured: 9, source: "config" }); + expect(parsed.liveness).toEqual({ status: "absent-proven", pid: null, port: null, source: null }); + } finally { + removeTreeWithRetry(home); + } + }); + + test("a configured custom port stays the effective port while nothing is live", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-resolve-custom-port-")); + try { + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 23456 }), "utf8"); + const run = await runResolveCli(["resolve", "--json"], home); + expect(run.exitCode).toBe(0); + const parsed = JSON.parse(run.stdout) as { port: { effective: number; configured: number; source: string } }; + expect(parsed.port).toEqual({ effective: 23456, configured: 23456, source: "config" }); + } finally { + removeTreeWithRetry(home); + } + }); + + test("any argument is a usage error before preflight side effects", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-resolve-usage-")); + try { + const run = await runResolveCli(["resolve", "extra"], home); + expect(run.exitCode).toBe(64); + expect(run.stdout).toBe(""); + expect(run.stderr).toContain("Usage: ocx resolve"); + } finally { + removeTreeWithRetry(home); + } + }); + + test("help is registered for the verb", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-resolve-help-")); + try { + mkdirSync(home, { recursive: true }); + const run = await runResolveCli(["help", "resolve"], home); + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Usage: ocx resolve"); + } finally { + removeTreeWithRetry(home); + } + }); + + test("the default form prints human output, not JSON", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-resolve-human-")); + try { + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 9 }), "utf8"); + const run = await runResolveCli(["resolve"], home); + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain(`Config home: ${home}`); + expect(run.stdout).toContain("No live proxy (absence proven); effective port 9 (configured)."); + } finally { + removeTreeWithRetry(home); + } + }); + + test("an invalid config.json is refused rather than resolved to defaults", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-resolve-invalid-config-")); + try { + writeFileSync(join(home, "config.json"), "{ not json", "utf8"); + const run = await runResolveCli(["resolve", "--json"], home); + expect(run.exitCode).toBe(1); + expect(run.stdout).toBe(""); + expect(run.stderr).toContain("refusing to guess"); + } finally { + removeTreeWithRetry(home); + } + }); +}); diff --git a/tests/cli/cli-resolve.test.ts b/tests/cli/cli-resolve.test.ts new file mode 100644 index 00000000000..69fbac9764b --- /dev/null +++ b/tests/cli/cli-resolve.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + RESOLVE_DEFAULT_PORT, + RESOLVE_SCHEMA, + buildResolveJson, + parseResolveArgs, + runResolve, +} from "../../src/cli/resolve"; +import type { LiveProxy } from "../../src/server/proxy-liveness"; +import type { ConfigDiagnostics } from "../../src/config"; +import { repoPath } from "../helpers/repo-root"; + +function fakeLive(overrides: Partial = {}): LiveProxy { + return { + pid: 4242, + port: 10110, + hostname: "127.0.0.1", + source: "runtime", + version: "9.9.9", + ...overrides, + }; +} + +describe("parseResolveArgs", () => { + test("accepts the bare verb and --json, rejects anything else with code 64", () => { + expect(parseResolveArgs([])).toEqual({ ok: true, args: { json: false } }); + expect(parseResolveArgs(["--json"])).toEqual({ ok: true, args: { json: true } }); + expect(parseResolveArgs(["--json", "--json"])).toEqual({ ok: true, args: { json: true } }); + for (const argv of [["extra"], ["--wait", "5"], ["-"], ["--json", "extra"]]) { + expect(parseResolveArgs(argv)).toEqual({ ok: false, code: 64 }); + } + }); +}); + +describe("buildResolveJson", () => { + test("a live runtime-record proxy answers with its own port and identity", () => { + const json = buildResolveJson({ port: 12345 }, fakeLive(), "/home/fixture/.opencodex", "1.2.3"); + expect(json).toEqual({ + schema: RESOLVE_SCHEMA, + cliVersion: "1.2.3", + configHome: "/home/fixture/.opencodex", + port: { effective: 10110, configured: 12345, source: "runtime" }, + liveness: { + status: "live", + pid: 4242, + port: 10110, + hostname: "127.0.0.1", + source: "runtime", + version: "9.9.9", + }, + }); + }); + + test("without a live proxy the configured port is the effective one", () => { + const json = buildResolveJson({ port: 12345 }, null, "/home/fixture/.opencodex", "1.2.3"); + expect(json.port).toEqual({ effective: 12345, configured: 12345, source: "config" }); + expect(json.liveness).toEqual({ status: "absent-proven", pid: null, port: null, source: null }); + }); + + test("an absent configured port resolves to the CLI default", () => { + const json = buildResolveJson({}, null, "/home/fixture/.opencodex", "1.2.3"); + expect(json.port).toEqual({ + effective: RESOLVE_DEFAULT_PORT, + configured: RESOLVE_DEFAULT_PORT, + source: "config", + }); + }); + + test("optional liveness identity fields are omitted, never null-coerced", () => { + const legacy = fakeLive({ version: undefined, role: undefined, hostname: undefined }); + const json = buildResolveJson({}, legacy, "/h", "1.2.3"); + expect(json.liveness).toEqual({ + status: "live", + pid: 4242, + port: 10110, + source: "runtime", + }); + }); +}); + +describe("runResolve", () => { + test("prints exactly one JSON document and exits 0 for a live proxy", async () => { + const lines: string[] = []; + const errors: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/home/fixture/.opencodex", + readDiagnostics: () => ({ config: { port: 12345 }, source: "file", error: null } as ConfigDiagnostics), + findLive: async () => fakeLive(), + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + stderr: { error: value => errors.push(value) }, + }); + expect(code).toBe(0); + expect(errors).toEqual([]); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!)).toEqual({ + schema: RESOLVE_SCHEMA, + cliVersion: "1.2.3", + configHome: "/home/fixture/.opencodex", + port: { effective: 10110, configured: 12345, source: "runtime" }, + liveness: { + status: "live", + pid: 4242, + port: 10110, + hostname: "127.0.0.1", + source: "runtime", + version: "9.9.9", + }, + }); + }); + + test("a proven-absent verdict is a successful answer, not a failure", async () => { + const lines: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: {}, source: "default", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => null, + probeEndpoint: () => "dead", + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + const parsed = JSON.parse(lines[0]!) as { liveness: { status: string }; port: { effective: number } }; + expect(parsed.liveness.status).toBe("absent-proven"); + expect(parsed.port.effective).toBe(RESOLVE_DEFAULT_PORT); + }); + + test("an undecidable probe is unknown, and unknown is never answered as absent", async () => { + // The launch decision keys on this verdict: a timed-out probe or a listener that + // withholds /healthz must exit 1 rather than let the caller start a second runtime. + for (const probeEndpoint of [() => "unknown" as const, () => { throw new Error("spawn unavailable"); }]) { + const lines: string[] = []; + const errors: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: {}, source: "default", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => null, + probeEndpoint, + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + stderr: { error: value => errors.push(value) }, + }); + expect(code).toBe(1); + expect(lines).toEqual([]); + expect(errors.join("\n")).toContain("unknown"); + } + }); + + test("absence requires every endpoint dead, not just the configured one", async () => { + // The runtime record can point at a live port while the configured port refuses; + // answering from the configured port alone would shadow-start over the record. + // everyEndpointProvenDown short-circuits on the first non-dead answer: an unknown + // runtime endpoint defeats the proof without the configured one being probed. + const seen: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: { port: 10100 }, source: "file", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => ({ port: 10110, hostname: "127.0.0.1" }), + probeEndpoint: endpoint => { seen.push(String(endpoint.port)); return endpoint.port === 10110 ? "unknown" : "dead"; }, + cliVersion: () => "1.2.3", + stdout: { log: () => {} }, + stderr: { error: () => {} }, + }); + expect(code).toBe(1); + expect(seen).toContain("10110"); + }); + + test("proven absent probes both the runtime record and the configured port", async () => { + const seen: string[] = []; + const lines: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: { port: 10100 }, source: "file", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => ({ port: 10110, hostname: "127.0.0.1" }), + probeEndpoint: endpoint => { seen.push(String(endpoint.port)); return "dead"; }, + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + expect(seen).toEqual(["10110", "10100"]); + expect((JSON.parse(lines[0]!) as { liveness: { status: string } }).liveness.status).toBe("absent-proven"); + }); + + test("a config read failure exits 1 with nothing on stdout", async () => { + const lines: string[] = []; + const errors: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => { throw new Error("config.json is not readable"); }, + findLive: async () => null, + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + stderr: { error: value => errors.push(value) }, + }); + expect(code).toBe(1); + expect(lines).toEqual([]); + expect(errors.join("\n")).toContain("config.json is not readable"); + }); + + test("an invalid config is refused, not repaired to defaults", async () => { + // loadConfig repairs a broken config to factory defaults; a shell contract must not + // answer 10100 for a config the operator pointed at another port. The diagnostics + // surface distinguishes that case (source "fallback") so resolve can exit 1. + const lines: string[] = []; + const errors: string[] = []; + let probed = false; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: {}, source: "fallback", error: "invalid_json" } as ConfigDiagnostics), + findLive: async () => { probed = true; return null; }, + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + stderr: { error: value => errors.push(value) }, + }); + expect(code).toBe(1); + expect(lines).toEqual([]); + expect(errors.join("\n")).toContain("refusing to guess"); + // No liveness probe may run against a guessed port. + expect(probed).toBe(false); + }); + + test("the production default probes with the ownership-safe budget", () => { + // Source oracle: the verdict feeds the shell's launch decision, so it borrows the + // start path's START_OWNERSHIP_LIVENESS budget instead of the 750ms single probe. + const src = readFileSync(repoPath("src", "cli", "resolve.ts"), "utf8"); + expect(src).toContain("findLiveProxy(START_OWNERSHIP_LIVENESS)"); + }); + + test("the default output is two human lines, never JSON", async () => { + const lines: string[] = []; + const code = await runResolve({ json: false }, { + configDir: () => "/home/fixture/.opencodex", + readDiagnostics: () => ({ config: { port: 12345 }, source: "file", error: null } as ConfigDiagnostics), + findLive: async () => fakeLive(), + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + expect(lines).toHaveLength(2); + expect(lines[0]).toBe("Config home: /home/fixture/.opencodex"); + expect(lines[1]).toContain("Proxy live on port 10110 (PID 4242, 9.9.9)"); + expect(lines.every(line => { try { JSON.parse(line); return false; } catch { return true; } })).toBe(true); + }); + + test("human output for a proven-absent verdict names the effective port", async () => { + const lines: string[] = []; + const code = await runResolve({ json: false }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: {}, source: "default", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => null, + probeEndpoint: () => "dead", + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + expect(lines[1]).toBe(`No live proxy (absence proven); effective port ${RESOLVE_DEFAULT_PORT} (configured).`); + }); +}); diff --git a/tests/cli/cli-stop-json.test.ts b/tests/cli/cli-stop-json.test.ts new file mode 100644 index 00000000000..2ea64a40713 --- /dev/null +++ b/tests/cli/cli-stop-json.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, test } from "bun:test"; +import { dispatchCommand } from "../../src/cli/dispatch"; +import type { CliDispatchDeps } from "../../src/cli/dispatch"; +import type { CliHead } from "../../src/cli/root"; +import { + STOP_SUMMARY_SCHEMA, + summarizeStopRun, + type StopRunRecord, + type StopSummaryJson, +} from "../../src/cli/stop-report"; +import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../../src/update/stop-contract.mjs"; + +/** + * A test batch shares ONE Bun process, and Bun does not clear process.exitCode on a + * bare undefined assignment — the suite convention is restore-with-`?? 0` + * (cli-dispatch.test.ts, service.test.ts). A leftover nonzero exitCode fails the batch + * with zero failing tests, which is exactly what shard batch output then shows. + */ +function withExitCode(code: number): () => void { + const previousExitCode = process.exitCode; + process.exitCode = code; + return () => { + process.exitCode = previousExitCode ?? 0; + }; +} + +function record(overrides: Partial = {}): StopRunRecord { + return { + service: "absent", + proxy: "stopped", + sharedTeardown: "restored", + inheritedTeardownBlocks: false, + receiptClearFailed: false, + ...overrides, + }; +} + +function summarize(rec: StopRunRecord, signals: { failed?: boolean; historyOnly?: boolean; historyDeferred?: boolean; exitCode?: number }): StopSummaryJson { + return summarizeStopRun(rec, { + failed: signals.failed ?? false, + historyOnly: signals.historyOnly ?? false, + historyDeferred: signals.historyDeferred ?? false, + exitCode: signals.exitCode ?? 0, + }); +} + +describe("summarizeStopRun", () => { + test("a clean tracked-pid stop is stopped, ok, and runtime-down", () => { + const summary = summarize(record(), { exitCode: 0 }); + expect(summary).toEqual({ + schema: STOP_SUMMARY_SCHEMA, + ok: true, + outcome: "stopped", + exitCode: 0, + runtimeDown: true, + service: "absent", + proxy: "stopped", + sharedTeardown: "restored", + message: "The proxy stopped.", + }); + }); + + test("nothing running is a successful not-running outcome", () => { + const summary = summarize(record({ proxy: "not-running", sharedTeardown: "restored" }), { exitCode: 0 }); + expect(summary.outcome).toBe("not-running"); + expect(summary.ok).toBe(true); + expect(summary.runtimeDown).toBe(true); + expect(summary.message).toBe("No proxy was running."); + }); + + test("history-incomplete keeps the proxy down with its own exit code", () => { + const summary = summarize(record(), { historyOnly: true, exitCode: STOP_HISTORY_INCOMPLETE_EXIT_CODE }); + expect(summary.outcome).toBe("history-incomplete"); + expect(summary.ok).toBe(false); + expect(summary.runtimeDown).toBe(true); + }); + + test("history-deferred is only clean with the deferred exit code", () => { + const clean = summarize(record({ sharedTeardown: "refused" }), { + historyDeferred: true, + exitCode: STOP_HISTORY_DEFERRED_EXIT_CODE, + }); + expect(clean.outcome).toBe("history-deferred"); + expect(clean.message).toContain("refused"); + + const dishonest = summarize(record({ sharedTeardown: "refused" }), { historyDeferred: true, exitCode: 1 }); + expect(dishonest.outcome).toBe("failed"); + }); + + test("a respawned survivor fails with the runtime up", () => { + const summary = summarize(record({ proxy: "respawned", service: "stopped-respawnable", sharedTeardown: "skipped" }), { + failed: true, + exitCode: 1, + }); + expect(summary.outcome).toBe("failed"); + expect(summary.ok).toBe(false); + expect(summary.runtimeDown).toBe(false); + expect(summary.message).toContain("respawned"); + }); + + test("an ownership refusal names the refusing proxy", () => { + const summary = summarize(record({ proxy: "ownership-refused", sharedTeardown: "skipped" }), { failed: true, exitCode: 1 }); + expect(summary.outcome).toBe("failed"); + expect(summary.runtimeDown).toBe(false); + expect(summary.message).toContain("refused the stop"); + }); + + test("an unresolvable pid is reported rather than treated as stopped", () => { + const summary = summarize(record({ proxy: "unresolvable-pid", sharedTeardown: "skipped" }), { failed: true, exitCode: 1 }); + expect(summary.runtimeDown).toBe(false); + expect(summary.message).toContain("process id could not be resolved"); + }); + + test("a service-manager failure outranks the stopped proxy for the message", () => { + const summary = summarize(record({ service: "failed" }), { failed: true, exitCode: 1 }); + expect(summary.message).toContain("service manager did not stop"); + }); + + test("inherited teardown blocks and receipt-clear failures are surfaced", () => { + const inherited = summarize(record({ inheritedTeardownBlocks: true, sharedTeardown: "skipped" }), { failed: true, exitCode: 1 }); + expect(inherited.message).toContain("outstanding shared teardown"); + + const receipt = summarize(record({ receiptClearFailed: true }), { failed: true, exitCode: 1 }); + expect(receipt.message).toContain("receipt"); + }); + + test("the proxy-owned teardown keeps its own classification", () => { + const summary = summarize(record({ sharedTeardown: "performed-by-proxy" }), { exitCode: 0 }); + expect(summary.sharedTeardown).toBe("performed-by-proxy"); + expect(summary.outcome).toBe("stopped"); + }); + + test("a failed shared teardown is not reported as restored", () => { + // restore.other means the config/catalog restore failed: the proxy is down but the + // client config may still point at it. The summary must say failed, not restored. + const summary = summarize(record({ sharedTeardown: "failed" }), { failed: true, exitCode: 1 }); + expect(summary.outcome).toBe("failed"); + expect(summary.runtimeDown).toBe(true); + expect(summary.sharedTeardown).toBe("failed"); + expect(summary.message).toContain("teardown failed"); + }); +}); + +describe("ocx stop --json dispatch", () => { + function captureConsole() { + const originalLog = console.log; + const originalError = console.error; + const stdout: string[] = []; + const stderr: string[] = []; + console.log = (...values: unknown[]) => { stdout.push(values.map(String).join(" ")); }; + console.error = (...values: unknown[]) => { stderr.push(values.map(String).join(" ")); }; + return { + stdout, + stderr, + restore() { + console.log = originalLog; + console.error = originalError; + }, + }; + } + + function stopDeps(handleStop: CliDispatchDeps["handleStop"], args: string[]): CliDispatchDeps { + const head: CliHead = { kind: "command", command: "stop", args }; + return { args, head, handleStop } as unknown as CliDispatchDeps; + } + + test("emits exactly one JSON document and moves human output to stderr", async () => { + const restoreExitCode = withExitCode(0); + const summary = summarizeStopRun(record(), { failed: false, historyOnly: false, historyDeferred: false, exitCode: 0 }); + const captured = captureConsole(); + try { + const code = await dispatchCommand( + { kind: "command", command: "stop", args: ["stop", "--json"] }, + stopDeps(async () => { + // The real handleStop logs through console.log; the JSON dispatch must move + // that stream to stderr for the duration of the stop call. + console.log("Service manager stopped."); + return { ok: true, summary }; + }, ["stop", "--json"]), + ); + expect(code).toBe(0); + expect(captured.stdout).toHaveLength(1); + expect(JSON.parse(captured.stdout[0]!)).toEqual(summary); + // The stop path's human line and the downtime warning are human output: stderr + // in JSON mode. + expect(captured.stderr.some(line => line.includes("Service manager stopped."))).toBe(true); + expect(captured.stderr.some(line => line.includes("will fail until it is restarted"))).toBe(true); + } finally { + captured.restore(); + restoreExitCode(); + } + }); + + test("preserves the history exit codes across the JSON boundary", async () => { + const restoreExitCode = withExitCode(STOP_HISTORY_INCOMPLETE_EXIT_CODE); + const summary = summarizeStopRun(record(), { + failed: false, + historyOnly: true, + historyDeferred: false, + exitCode: STOP_HISTORY_INCOMPLETE_EXIT_CODE, + }); + const captured = captureConsole(); + try { + const code = await dispatchCommand( + { kind: "command", command: "stop", args: ["stop", "--json"] }, + stopDeps(async () => ({ ok: true, summary }), ["stop", "--json"]), + ); + expect(code).toBe(STOP_HISTORY_INCOMPLETE_EXIT_CODE); + expect((JSON.parse(captured.stdout[0]!) as StopSummaryJson).exitCode).toBe(STOP_HISTORY_INCOMPLETE_EXIT_CODE); + } finally { + captured.restore(); + restoreExitCode(); + } + }); + + test("without --json the human output and warning stay on stdout", async () => { + const restoreExitCode = withExitCode(0); + const summary = summarizeStopRun(record(), { failed: false, historyOnly: false, historyDeferred: false, exitCode: 0 }); + const captured = captureConsole(); + try { + const code = await dispatchCommand( + { kind: "command", command: "stop", args: ["stop"] }, + stopDeps(async () => { + console.log("Service manager stopped."); + return { ok: true, summary }; + }, ["stop"]), + ); + expect(code).toBe(0); + expect(captured.stdout.some(line => line.includes("Service manager stopped."))).toBe(true); + expect(captured.stdout.some(line => line.includes("will fail until it is restarted"))).toBe(true); + expect(captured.stdout.every(line => { try { JSON.parse(line); return false; } catch { return true; } })).toBe(true); + } finally { + captured.restore(); + restoreExitCode(); + } + }); + + test("a failed stop never prints the downtime warning in human mode", async () => { + // handleStop now returns { ok, summary }: an object is always truthy, so keying the + // warning on the return value would print "requests will fail" for a failed stop. + const restoreExitCode = withExitCode(1); + const summary = summarizeStopRun(record({ proxy: "stop-failed", sharedTeardown: "skipped" }), { + failed: true, historyOnly: false, historyDeferred: false, exitCode: 1, + }); + const captured = captureConsole(); + try { + const code = await dispatchCommand( + { kind: "command", command: "stop", args: ["stop"] }, + stopDeps(async () => ({ ok: false, summary }), ["stop"]), + ); + expect(code).toBe(1); + expect(captured.stdout.some(line => line.includes("will fail until it is restarted"))).toBe(false); + } finally { + captured.restore(); + restoreExitCode(); + } + }); +}); diff --git a/tests/codex-integration/codex-shim-autorestore.test.ts b/tests/codex-integration/codex-shim-autorestore.test.ts index 75562bbc567..5f942c3f5dc 100644 --- a/tests/codex-integration/codex-shim-autorestore.test.ts +++ b/tests/codex-integration/codex-shim-autorestore.test.ts @@ -48,6 +48,10 @@ describe("Codex shim CLI auto-restore policy", () => { expect(skipsCodexShimAutoRestore("system", args)).toBe(true); } expect(skipsCodexShimAutoRestore("system", ["system", "update", "check"])).toBe(false); + // resolve is read-only inspection for embedding shells: a lookup made to populate a + // consent surface must not trigger a shim repair side effect first. + expect(skipsCodexShimAutoRestore("resolve", ["resolve"])).toBe(true); + expect(skipsCodexShimAutoRestore("resolve", ["resolve", "--json"])).toBe(true); expect(skipsCodexShimAutoRestore("status", ["status"])).toBe(false); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index da49f05ab6c..a5659b23252 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -254,6 +254,8 @@ "cli-ready-subprocess.test.ts": "cli", "cli-ready.test.ts": "cli", "cli-registry.test.ts": "cli", + "cli-resolve-subprocess.test.ts": "cli", + "cli-resolve.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", "cli-start-auxiliary-bind.test.ts": "cli", @@ -261,6 +263,7 @@ "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", + "cli-stop-json.test.ts": "cli", "cli-storage-inspect.test.ts": "cli", "cli-transport-honesty.test.ts": "cli", "cli-usage-report.test.ts": "cli", diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index 6968bedceab..4c930e57f5d 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -164,7 +164,9 @@ describe("Grok fence lifecycle wiring", () => { const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); // process.exit() inside handleStop would strand runTrayProxyRestart's start() half. expect(stopFn).toContain("process.exitCode = 1"); - expect(stopFn).toContain("return !stopFailed"); + // The structured outcome (the stop --json summary) keeps the old boolean as ok, so + // the dispatcher's downtime-warning gate is byte-for-byte the pre-summary semantics. + expect(stopFn).toContain("return { ok: !stopFailed, summary };"); expect(stopFn).not.toContain("process.exit(1)"); const restartCase = sliceFn(DISPATCH_SOURCE, "restart: async", "health: async"); diff --git a/tests/update/update-stop-classification.test.ts b/tests/update/update-stop-classification.test.ts index f14334dff5a..13ddae767aa 100644 --- a/tests/update/update-stop-classification.test.ts +++ b/tests/update/update-stop-classification.test.ts @@ -316,8 +316,10 @@ describe("stop failure classification (#3008)", () => { expect(cli).toMatch(/if \(stopFailed\) process\.exitCode = 1;\s*\n\s*else if \(historyOnlyFailure\) process\.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE;/); // The code is set rather than exited inline so the dispatcher still receives the // return value and decides what happens next. The deferred code (#4718) sits between - // them and obeys the same rule, so the function still ends by returning. - expect(cli).toMatch(/process\.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE;[\s\S]*?\n\s*return !stopFailed;\n\}/); + // them and obeys the same rule, so the function still ends by returning. The return + // is a structured outcome since the stop summary landed: ok preserves the old + // boolean exactly, and the summary derives from the same signals. + expect(cli).toMatch(/process\.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE;[\s\S]*?\n\s*return \{ ok: !stopFailed, summary \};\n\}/); // The deferred code never outranks an ordinary failure, and it is only reachable when // this run can still prove the obligations left behind are the ones it chose to keep. expect(cli).toMatch(/else if \(historyDeferredNonces\) \{/); From 03ab5bfb113d47dbf737752e0ffcdcc47387e9ce Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:20:35 +0900 Subject: [PATCH 64/87] fix(chat): share explicit reasoning wire policy (#5398) * test(openai-chat): cover reasoning wire policy parity * fix(openai-chat): share explicit reasoning wire policy --- scripts/test-layout/layout.json | 1 + src/adapters/openai-chat.ts | 59 ++++-------- src/adapters/openai-chat/passthrough.ts | 17 +++- src/adapters/openai-chat/reasoning-wire.ts | 89 +++++++++++++++++++ structure/adapters/registry.md | 2 +- structure/data-planes/inbound-compat.md | 5 +- structure/providers-and-adapters.md | 2 +- structure/providers/chat-compat.md | 4 +- structure/transports/inventory.md | 2 +- .../openai-chat-reasoning-wire-policy.test.ts | 84 +++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 11 files changed, 215 insertions(+), 51 deletions(-) create mode 100644 src/adapters/openai-chat/reasoning-wire.ts create mode 100644 tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 73cea68be35..881526d6ff8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1084,6 +1084,7 @@ "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", + "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index fa024e0cccb..98ba27cd203 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,5 +1,6 @@ import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; +import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wire"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; import { modelInList } from "../types"; @@ -41,7 +42,7 @@ import { } from "./openai-chat/errors"; import { messagesToChatFormat } from "./openai-chat/messages"; import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; -import { isNativeOpenAIChatTarget, openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; +import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; export { stripBracketedModelSuffix } from "./openai-chat/wire"; @@ -143,51 +144,21 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences; const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId); - // Some gateways accept a reasoning-effort field on a plain turn but reject the - // effort + tools combination. `noReasoningModels` would fix that only by - // stripping reasoning everywhere, costing the model its whole picker. This keeps - // the ladder advertised and drops the wire field for tool-bearing requests only. - const omitReasoningEffortWithTools = !!tools - && modelInList(provider.omitReasoningEffortWithToolsModels, parsed.modelId); - const reasoningEffort = omitReasoningEffortWithTools - ? undefined - : mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); - const nativeOpenAI = isNativeOpenAIChatTarget(provider); + const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + const explicitReasoning = applyExplicitChatReasoningWirePolicy({ + provider, + modelId: parsed.modelId, + hasTools: !!tools, + requestedEffort: parsed.options.reasoning, + wireEffort: reasoningEffort, + reasoningDisabled, + body, + }); let reasoningLog: AdapterRequest["reasoningLog"]; - if (!reasoningDisabled && !omitReasoningEffortWithTools && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") { - if (nativeOpenAI) { - body.reasoning_effort = "none"; - reasoningLog = { - effectiveEffort: "none", - wireField: "reasoning_effort", - wireValue: "none", - }; - } else { - body.reasoning = { enabled: false }; - reasoningLog = { - effectiveEffort: "none", - wireField: "reasoning.enabled", - wireValue: false, - }; - } + if (explicitReasoning.handled) { + reasoningLog = explicitReasoning.reasoningLog; } else if (reasoningEffort !== undefined) { - if (provider.reasoningWireFormat === "gateway-object") { - if (nativeOpenAI) { - body.reasoning_effort = reasoningEffort; - reasoningLog = { - effectiveEffort: reasoningEffort, - wireField: "reasoning_effort", - wireValue: reasoningEffort, - }; - } else { - body.reasoning = { enabled: true, effort: reasoningEffort }; - reasoningLog = { - effectiveEffort: reasoningEffort, - wireField: "reasoning.effort", - wireValue: reasoningEffort, - }; - } - } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) { + if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) { const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens); if (budget !== undefined) { body.thinking_budget = budget; diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index 2f13ebd8990..16abcf08d83 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -11,6 +11,7 @@ import { isDebugEnabled } from "../../lib/debug-settings"; import { modelRecordValue } from "../../reasoning-effort"; import { modelInList, type OcxProviderConfig } from "../../types"; import { chatParallelToolCallsWireValue } from "./parallel-tool-calls"; +import { applyExplicitChatReasoningWirePolicy } from "./reasoning-wire"; const CHAT_PASSTHROUGH_FIELDS = [ "audio", @@ -72,9 +73,21 @@ export function buildOpenAIChatPassthroughRequest( if (rawBody[field] !== undefined) body[field] = rawBody[field]; } const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; - if (modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0) { + const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0; + if (reasoningDisabled) { delete body.reasoning_effort; } + const hasTools = Array.isArray(rawBody.tools) && rawBody.tools.length > 0; + const requestedEffort = typeof body.reasoning_effort === "string" ? body.reasoning_effort : undefined; + applyExplicitChatReasoningWirePolicy({ + provider, + modelId, + hasTools, + requestedEffort, + wireEffort: requestedEffort, + reasoningDisabled, + body, + }); const openRouterRouting = resolveOpenRouterRouting(provider, modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); @@ -118,7 +131,7 @@ export function buildOpenAIChatPassthroughRequest( if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) { body.prompt_cache_key = rawBody.prompt_cache_key; } - if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) { + if (hasTools) { // Same three provider states as the translated path, and the same defect in the unset one: // a caller's explicit false was dropped here too (#5211). The native route reads the bit off // the raw request rather than the parsed options, since nothing projects this body. diff --git a/src/adapters/openai-chat/reasoning-wire.ts b/src/adapters/openai-chat/reasoning-wire.ts new file mode 100644 index 00000000000..ac53a242352 --- /dev/null +++ b/src/adapters/openai-chat/reasoning-wire.ts @@ -0,0 +1,89 @@ +import { modelInList, type OcxProviderConfig } from "../../types"; +import type { AdapterRequest } from "../base"; +import { isNativeOpenAIChatTarget } from "./wire"; + +export type ExplicitChatReasoningWireResult = + | { handled: false } + | { handled: true; reasoningLog?: AdapterRequest["reasoningLog"] }; + +/** + * Apply provider-declared reasoning wire policy after the effective provider is resolved. + * Unset declarations are a no-op so native Chat keeps forwarding the caller's raw field. + */ +export function applyExplicitChatReasoningWirePolicy(options: { + provider: OcxProviderConfig; + modelId: string; + hasTools: boolean; + requestedEffort: string | undefined; + wireEffort: string | undefined; + reasoningDisabled: boolean; + body: Record; +}): ExplicitChatReasoningWireResult { + const { + provider, + modelId, + hasTools, + requestedEffort, + wireEffort, + reasoningDisabled, + body, + } = options; + + if (reasoningDisabled) return { handled: false }; + if (hasTools && modelInList(provider.omitReasoningEffortWithToolsModels, modelId)) { + delete body.reasoning_effort; + delete body.reasoning; + return { handled: true }; + } + if (provider.reasoningWireFormat !== "gateway-object") return { handled: false }; + + const nativeOpenAI = isNativeOpenAIChatTarget(provider); + if (requestedEffort === "none") { + if (nativeOpenAI) { + delete body.reasoning; + body.reasoning_effort = "none"; + return { + handled: true, + reasoningLog: { + effectiveEffort: "none", + wireField: "reasoning_effort", + wireValue: "none", + }, + }; + } + delete body.reasoning_effort; + body.reasoning = { enabled: false }; + return { + handled: true, + reasoningLog: { + effectiveEffort: "none", + wireField: "reasoning.enabled", + wireValue: false, + }, + }; + } + if (wireEffort === undefined) return { handled: false }; + + if (nativeOpenAI) { + delete body.reasoning; + body.reasoning_effort = wireEffort; + return { + handled: true, + reasoningLog: { + effectiveEffort: wireEffort, + wireField: "reasoning_effort", + wireValue: wireEffort, + }, + }; + } + delete body.reasoning_effort; + body.reasoning = { enabled: true, effort: wireEffort }; + return { + handled: true, + reasoningLog: { + effectiveEffort: wireEffort, + wireField: "reasoning.effort", + wireValue: wireEffort, + }, + }; +} diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a21be43a466..d1d67b5dc31 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -174,7 +174,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary. Translated and native Chat builders share explicit gateway-object and tool-bearing effort-omission policy after provider resolution; native Chat otherwise preserves caller controls and removes effort for an explicit empty declaration or no-reasoning model. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 6b87977760f..d063adc30a5 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -94,7 +94,10 @@ take the Chat -> Responses -> Chat bridge below. `parallel_tool_calls` is emitte parallel tools (or pinned false by the existing provider opt-out contract). The native passthrough still applies the existing model capability authority to reasoning: an explicit empty ladder removes caller `reasoning_effort`, while an unknown ladder remains -unclassified. This guard does not alter the separate raw service-tier contract. +unclassified. The two Chat builders share the explicit wire policy after provider resolution: +`reasoningWireFormat: "gateway-object"` projects the configured object shape, and a listed +tool-bearing model omits reasoning effort on both paths. With neither declaration, native raw +reasoning forwarding stays unchanged. This guard does not alter the separate raw service-tier contract. On the response side, the upstream `service_tier` echo (xAI Priority Processing, OpenAI fast tier) relays to the Chat Completions caller on every delivery shape: the non-streaming body diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index de68ef9f7ae..7dda9c214c6 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -15,7 +15,7 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | +| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 41c9f8b1f35..1c7465bde6e 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -77,7 +77,9 @@ boundary, so parsed messages and stored raw history retain the same task/guidanc Native OpenAI passthrough consults the existing configured capability ladder before forwarding `reasoning_effort`; an explicitly empty ladder removes that unsupported control while an unknown -ladder remains unclassified. It also sanitizes routed reasoning history so `reasoning` input items do not send +ladder remains unclassified. Both Chat builders then apply the same explicit provider declarations: +gateway-object projection and tool-bearing model effort omission. An unset declaration preserves the +native caller field exactly. Native passthrough also sanitizes routed reasoning history so `reasoning` input items do not send non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. It also repairs the opposite direction (260718): an assistant `tool_calls` round left dangling — diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ed5b26d601e..bfd34825f7d 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -36,7 +36,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | | Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | -| Chat Completions inbound | `src/server/chat-completions.ts`, `src/server/chat-native.ts`, `src/chat/`, `src/adapters/openai-chat.ts` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. The native handler owns pin/cap normalization; the adapter wire builder removes effort only for explicit empty declarations or no-reasoning models, preserving unknown raw declarations. On the response side, the upstream `service_tier` echo relays on every delivery shape (`src/chat/outbound.ts` projections, `src/server/chat-native-sse.ts` chunks); an upstream without the field gets no injected key. | +| Chat Completions inbound | `src/server/chat-completions.ts`, `src/server/chat-native.ts`, `src/chat/`, `src/adapters/openai-chat.ts` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. The native handler owns pin/cap normalization; both adapter builders share explicit gateway-object and tool-bearing effort-omission policy, while the native builder preserves unknown or undeclared raw behavior and removes effort for explicit empty declarations or no-reasoning models. On the response side, the upstream `service_tier` echo relays on every delivery shape (`src/chat/outbound.ts` projections, `src/server/chat-native-sse.ts` chunks); an upstream without the field gets no injected key. | | Hosted search relay | `src/server/search.ts` | Verbatim ChatGPT relay, or an explicitly configured web-search sidecar backend when no forward provider exists; distinct from the web-search sidecar loop below. | | Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | diff --git a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts new file mode 100644 index 00000000000..5814291eef6 --- /dev/null +++ b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { + buildOpenAIChatPassthroughRequest, + createOpenAIChatAdapter, +} from "../../../src/adapters/openai-chat"; +import { chatCompletionsToResponsesBody } from "../../../src/chat/inbound"; +import { parseRequest } from "../../../src/responses/parser"; +import type { OcxProviderConfig } from "../../../src/types"; + +const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.test/v1", + apiKey: "sk-test", + authMode: "key", + reasoningWireFormat: "gateway-object", + omitReasoningEffortWithToolsModels: ["reasoning-policy-model"], +}; +const modelId = provider.omitReasoningEffortWithToolsModels![0]!; + +const input: Record = { + model: modelId, + messages: [{ role: "user", content: "What is the weather?" }], + reasoning_effort: "none", +}; +const tool: Record = { + type: "function", + function: { + name: "get_weather", + description: "Get the weather for a city", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + additionalProperties: false, + }, + }, +}; +const inputWithTool: Record = { ...input, tools: [tool] }; + +function finalBodies(rawInput: Record): Record<"translated" | "native", Record> { + const parsed = parseRequest(chatCompletionsToResponsesBody(rawInput)); + const translated = createOpenAIChatAdapter(provider).buildRequest(parsed); + const native = buildOpenAIChatPassthroughRequest(provider, rawInput, modelId, false); + + return { + translated: JSON.parse(translated.body) as Record, + native: JSON.parse(native.body) as Record, + }; +} + +describe("OpenAI Chat reasoning wire policy parity", () => { + test("plain reasoning disable uses the gateway object on both final wires", () => { + const bodies = finalBodies(input); + const projection = Object.fromEntries(Object.entries(bodies).map(([builder, body]) => [ + builder, + { + reasoning: body.reasoning, + hasReasoningEffort: Object.hasOwn(body, "reasoning_effort"), + }, + ])); + + expect(projection).toEqual({ + translated: { reasoning: { enabled: false }, hasReasoningEffort: false }, + native: { reasoning: { enabled: false }, hasReasoningEffort: false }, + }); + }); + + test("a function tool survives while both final wires omit all reasoning fields", () => { + const bodies = finalBodies(inputWithTool); + const projection = Object.fromEntries(Object.entries(bodies).map(([builder, body]) => [ + builder, + { + tools: body.tools, + hasReasoning: Object.hasOwn(body, "reasoning"), + hasReasoningEffort: Object.hasOwn(body, "reasoning_effort"), + }, + ])); + + expect(projection).toEqual({ + translated: { tools: [tool], hasReasoning: false, hasReasoningEffort: false }, + native: { tools: [tool], hasReasoning: false, hasReasoningEffort: false }, + }); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a5659b23252..bccdcc68608 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -910,6 +910,7 @@ "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", + "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", From ebaf78a46c43e9babfcf4c75d80dc05767cb6b8f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:41:07 +0900 Subject: [PATCH 65/87] Preserve tool identity through response restoration (#5397) * fix: preserve restored tool identity scope * test: cover restored tool scope correspondence * docs: record restored tool identity scope * test: activate converted custom reconstruction --- src/responses/muse-tool-name-alias.ts | 19 +++ src/server/grok-responses-snapshot-repair.ts | 13 +- src/server/responses-request-tool-scope.ts | 153 ++++++++++++++---- src/server/responses/passthrough-delivery.ts | 15 +- structure/transports/responses.md | 10 ++ .../responses-muse-tool-name-alias.test.ts | 126 +++++++++++++++ ...sponses-sparse-terminal-tool-scope.test.ts | 97 ++++++++++- 7 files changed, 395 insertions(+), 38 deletions(-) diff --git a/src/responses/muse-tool-name-alias.ts b/src/responses/muse-tool-name-alias.ts index 1569c5785de..0208be86ba1 100644 --- a/src/responses/muse-tool-name-alias.ts +++ b/src/responses/muse-tool-name-alias.ts @@ -8,6 +8,25 @@ const MUSE_SAFE_NAME = /^[a-zA-Z0-9_-]+$/; export type MuseToolNameAliases = ReadonlyMap; +/** + * Final wire name for an identity before the Muse length rewrite. + * + * The alias map is request-local and already narrowed by `tool_choice`. Returning undefined for + * a contradictory map keeps downstream identity checks fail-closed instead of choosing one alias. + */ +export function museWireNameForOriginal( + originalName: string, + aliases: MuseToolNameAliases, +): string | undefined { + let wireName: string | undefined; + for (const [candidate, original] of aliases) { + if (original !== originalName) continue; + if (wireName !== undefined && wireName !== candidate) return undefined; + wireName = candidate; + } + return wireName ?? originalName; +} + function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } diff --git a/src/server/grok-responses-snapshot-repair.ts b/src/server/grok-responses-snapshot-repair.ts index 5cbb32fd9c8..57d95ed4273 100644 --- a/src/server/grok-responses-snapshot-repair.ts +++ b/src/server/grok-responses-snapshot-repair.ts @@ -3,7 +3,11 @@ import type { TranslatorBudget } from "../lib/translator-budget"; import { MAX_COMPLETED_OUTPUT_ITEMS, MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES } from "./relay"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec"; -import { requestToolScope, type RequestToolScope } from "./responses-request-tool-scope"; +import { + requestToolScope, + type RequestToolScope, + type RequestToolScopeCorrespondence, +} from "./responses-request-tool-scope"; type SparseTerminalOpenItem = { type: string; @@ -240,8 +244,12 @@ function refusedTerminalBlock( export function createGrokResponsesSparseTerminalBlockRewrite( budget?: TranslatorBudget, outboundRequestBody?: unknown, + toolIdentityCorrespondence?: RequestToolScopeCorrespondence, ): SseBlockRewrite { - const toolScope: RequestToolScope | undefined = requestToolScope(outboundRequestBody); + const toolScope: RequestToolScope | undefined = requestToolScope( + outboundRequestBody, + toolIdentityCorrespondence, + ); const openItems = new Map(); const completedItems = new Map(); const withheldIndices = new Set(); @@ -425,4 +433,3 @@ export function createGrokResponsesSparseTerminalBlockRewrite( rewrite.dispose = reset; return rewrite; } - diff --git a/src/server/responses-request-tool-scope.ts b/src/server/responses-request-tool-scope.ts index 8531ff9b2e7..aa140aaa6ad 100644 --- a/src/server/responses-request-tool-scope.ts +++ b/src/server/responses-request-tool-scope.ts @@ -12,7 +12,12 @@ * that is the request the destination answered. A catalog that ends up empty there authorizes no * client call whatever the selector still says. */ -import { dottedToolName, namespacedToolName } from "../types"; +import type { MuseToolNameAliases } from "../responses/muse-tool-name-alias"; +import { museWireNameForOriginal } from "../responses/muse-tool-name-alias"; +import type { + RoutedNamespaceToolAliases, + RoutedNamespaceToolIdentity, +} from "../responses/namespace-tool-compat"; import { CLIENT_EXECUTED_CALL_TYPES, collectDeclaredWireToolNames, @@ -20,33 +25,111 @@ import { } from "./responses-undeclared-tool-guard"; import { isPlainObject } from "./responses-snapshot-codec"; -/** Every spelling one call item can be named by, so a selector match is not defeated by flattening. */ -function callNameSpellings(item: Record): readonly string[] { - const name = typeof item.name === "string" ? item.name : ""; - if (name.length === 0) return []; - const namespace = typeof item.namespace === "string" && item.namespace.length > 0 - ? item.namespace - : undefined; - if (!namespace) return [name]; - return [name, namespacedToolName(namespace, name), dottedToolName(namespace, name)]; +type ToolKind = "function" | "custom"; +const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; + +type ToolIdentity = Readonly<{ + kind: ToolKind; + name: string; + namespace?: string; +}>; + +export type RequestToolScopeCorrespondence = Readonly<{ + clientToolAuthorizationBody?: unknown; + routedNamespaceToolAliases?: RoutedNamespaceToolAliases; + routedMuseToolNameAliases?: MuseToolNameAliases; + convertedRoutedCustomToolNames?: ReadonlySet; +}>; + +function identityKey(identity: ToolIdentity): string { + return JSON.stringify([identity.kind, identity.namespace ?? null, identity.name]); } -/** The names one `tool_choice` entry selects; empty when the entry names no client tool. */ -function selectorNameSpellings(selector: unknown): readonly string[] { - if (!isPlainObject(selector)) return []; - const name = typeof selector.name === "string" ? selector.name : ""; - if (name.length === 0) return []; - const namespace = typeof selector.namespace === "string" && selector.namespace.length > 0 - ? selector.namespace +function clientNamespace(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 && value !== BUILTIN_FUNCTIONS_NAMESPACE + ? value : undefined; - if (!namespace) return [name]; - return [name, namespacedToolName(namespace, name), dottedToolName(namespace, name)]; +} + +function selectorIdentity(selector: unknown): ToolIdentity | undefined { + if (!isPlainObject(selector)) return undefined; + if (selector.type !== "function" && selector.type !== "custom") return undefined; + if (typeof selector.name !== "string" || selector.name.length === 0) return undefined; + if ("namespace" in selector && typeof selector.namespace !== "string") return undefined; + const namespace = clientNamespace(selector.namespace); + return { kind: selector.type, name: selector.name, ...(namespace ? { namespace } : {}) }; +} + +function callIdentity(item: Record): ToolIdentity | undefined { + const kind = item.type === "function_call" + ? "function" + : item.type === "custom_tool_call" + ? "custom" + : undefined; + if (!kind || typeof item.name !== "string" || item.name.length === 0) return undefined; + const namespace = clientNamespace(item.namespace); + return { kind, name: item.name, ...(namespace ? { namespace } : {}) }; +} + +function sameRestoredIdentity(left: ToolIdentity, right: RoutedNamespaceToolIdentity): boolean { + return left.namespace === right.namespace + && left.name === right.name + && left.kind === right.kind; +} + +/** + * Exact identities this restored call could have used on the final outbound wire. + * + * Namespace spellings come only from namespace-tool-compat's request-scoped, ambiguity-checked + * aliases. Muse aliases then compose over those wire names. A kind change is admitted only when + * the request recorded that exact custom identity as converted to a function. + */ +function outboundCallIdentities( + item: Record, + correspondence: RequestToolScopeCorrespondence, +): ReadonlySet { + const restored = callIdentity(item); + if (!restored) return new Set(); + const keys = new Set([identityKey(restored)]); + const museAliases = correspondence.routedMuseToolNameAliases ?? new Map(); + const convertedCustom = correspondence.convertedRoutedCustomToolNames ?? new Set(); + const originalSelection = isPlainObject(correspondence.clientToolAuthorizationBody) + ? toolSelection(correspondence.clientToolAuthorizationBody) + : UNRESTRICTED; + const originalSelectionAllowsRestored = originalSelection.kind === "allow" + && originalSelection.identities.has(identityKey(restored)); + + const addWireIdentity = ( + preMuseName: string, + clientKind: ToolKind, + conversionIdentityVerified: boolean, + ): void => { + const name = museWireNameForOriginal(preMuseName, museAliases); + if (name === undefined) return; + const kind = clientKind === "custom" + && convertedCustom.has(preMuseName) + && conversionIdentityVerified + ? "function" + : clientKind; + keys.add(identityKey({ kind, name })); + }; + + if (restored.namespace === undefined) { + addWireIdentity(restored.name, restored.kind, originalSelectionAllowsRestored); + } + for (const [wireName, identity] of correspondence.routedNamespaceToolAliases ?? new Map()) { + // namespace-tool-compat emits only aliases authorized under the outbound selector's kind, + // then restores `custom` provenance from the request's conversion set. That exact alias edge is + // already the proof that this custom-to-function transition belongs to this identity. + if (sameRestoredIdentity(restored, identity)) addWireIdentity(wireName, identity.kind, true); + } + return keys; } type ToolSelection = | { readonly kind: "unrestricted" } | { readonly kind: "deny_all" } - | { readonly kind: "allow"; readonly names: ReadonlySet }; + | { readonly kind: "allow"; readonly identities: ReadonlySet }; const UNRESTRICTED: ToolSelection = { kind: "unrestricted" }; @@ -63,18 +146,22 @@ function toolSelection(body: Record): ToolSelection { if (choice === "none") return { kind: "deny_all" }; if (!isPlainObject(choice)) return UNRESTRICTED; if (choice.type === "allowed_tools") { - if (!Array.isArray(choice.tools)) return UNRESTRICTED; - const names = new Set(); + const identities = new Set(); + if (!Array.isArray(choice.tools)) return { kind: "allow", identities }; for (const entry of choice.tools) { - for (const spelling of selectorNameSpellings(entry)) names.add(spelling); + const identity = selectorIdentity(entry); + if (identity) identities.add(identityKey(identity)); } // An allow-list carrying no client tool — emptied by normalization, or hosted entries only — // still bounds this turn: it allows no client call. - return { kind: "allow", names }; + return { kind: "allow", identities }; } if (choice.type === "function" || choice.type === "custom") { - const names = new Set(selectorNameSpellings(choice)); - return names.size > 0 ? { kind: "allow", names } : UNRESTRICTED; + const identity = selectorIdentity(choice); + return { + kind: "allow", + identities: new Set(identity ? [identityKey(identity)] : []), + }; } return UNRESTRICTED; } @@ -93,7 +180,10 @@ export type RequestToolScope = { * Returning undefined for an unrestricted request keeps every ordinary turn on the path it * already had: a caller that selected nothing gets no new refusal. */ -export function requestToolScope(body: unknown): RequestToolScope | undefined { +export function requestToolScope( + body: unknown, + correspondence: RequestToolScopeCorrespondence = {}, +): RequestToolScope | undefined { if (!isPlainObject(body)) return undefined; const selection = toolSelection(body); // A readable catalog that declares no client-executable name is authoritative, exactly as it is @@ -108,12 +198,15 @@ export function requestToolScope(body: unknown): RequestToolScope | undefined { if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) { return undefined; } - const spellings = callNameSpellings(item); - const reported = spellings[0]; + const restored = callIdentity(item); + const reported = restored?.name; if (reported === undefined) return undefined; if (catalogDeniesClientCalls || selection.kind === "deny_all") return reported; if (selection.kind === "allow") { - return spellings.some(spelling => selection.names.has(spelling)) ? undefined : reported; + const candidates = outboundCallIdentities(item, correspondence); + return [...candidates].some(candidate => selection.identities.has(candidate)) + ? undefined + : reported; } return undefined; }, diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index a8ee2f3f4b0..969cd84e589 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -93,6 +93,7 @@ import { createResponsesFieldBackfillBlockRewrite } from "./responses-field-back import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair"; import { createUndeclaredToolCallGuardBlockRewrite, + currentTurnWireToolCatalogBody, undeclaredToolCallNameInResponse, undeclaredToolCallMessage, normalizeDefaultNamespaceInJson, @@ -515,7 +516,19 @@ export async function deliverPassthroughResponse( ? createGrokResponsesTimestampBlockRewrite() : undefined, grokClientCompatibilityEnabled - ? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget, nativeExchange.outboundRequestBody) + ? createGrokResponsesSparseTerminalBlockRewrite( + translatorBudget, + nativeExchange.outboundRequestBody, + { + clientToolAuthorizationBody: currentTurnWireToolCatalogBody( + parsed._rawBody, + parsed._replayPrefixLen ?? 0, + ), + routedNamespaceToolAliases: responseEffects.routedNamespaceToolAliases, + routedMuseToolNameAliases: responseEffects.routedMuseToolNameAliases, + convertedRoutedCustomToolNames: routedCustomToolNames, + }, + ) : undefined, snapshotRepairEnabled ? createResponsesSnapshotBlockRewrite(nativeExchange.outboundRequestBody, translatorBudget) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b4399c97bab..56718320172 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -684,6 +684,16 @@ translation, so a catalog that ends up empty there authorizes no client call wha still says. An absent catalog states no boundary, exactly as it states none for the declaration guard. +Selection matching follows request-local identity correspondence instead of regenerating a set of +name spellings. The outbound selector keeps its exact kind and wire name; namespace lowering +contributes only its collision-checked `{namespace, name, kind}` aliases, and the Muse length rewrite +composes its final wire alias over those identities. After client-facing restoration, two namespaces +that share one basename remain distinct, as do a function and a custom tool that share one name. A +custom call may match a function selector only when the same request records that exact +custom-to-function conversion; malformed narrowing selectors and contradictory alias maps fail +closed. Payload restoration still precedes sparse-terminal reconstruction, so the scope compares the +restored call through that correspondence while preserving its item and call identifiers. + The refusal is narrow and it is visible. Only the offending item is dropped, so the assistant text that arrived in the same turn still reaches the client rather than being discarded with it. Because the turn no longer ended the way the upstream said it did, the reconstructed terminal is published diff --git a/tests/responses/responses-muse-tool-name-alias.test.ts b/tests/responses/responses-muse-tool-name-alias.test.ts index 30f76330d48..30fc04cfee3 100644 --- a/tests/responses/responses-muse-tool-name-alias.test.ts +++ b/tests/responses/responses-muse-tool-name-alias.test.ts @@ -9,6 +9,7 @@ import { rewriteMuseToolNamesForUpstream, } from "../../src/responses/muse-tool-name-alias"; import { expandPreviousResponseInput } from "../../src/responses/state"; +import { GROK_REFUSED_TERMINAL_EVENT_TYPE } from "../../src/server/grok-responses-snapshot-repair"; import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -269,6 +270,17 @@ describe("muse tool-name inbound restore through handleResponses", () => { const frame = (event: string, payload: Record): string => `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; + const ssePayloads = (text: string): Array> => text + .split(/\r?\n\r?\n/u) + .flatMap(block => { + const data = block.split(/\r?\n/u) + .filter(line => line.startsWith("data: ")) + .map(line => line.slice("data: ".length)) + .join(""); + if (data.length === 0 || data === "[DONE]") return []; + return [JSON.parse(data) as Record]; + }); + test("non-stream function_call and tool_choice restore the original MCP name", async () => { const savedFetch = globalThis.fetch; let outbound: Record | undefined; @@ -336,6 +348,120 @@ describe("muse tool-name inbound restore through handleResponses", () => { } }); + for (const selectorKind of ["named", "allowed_tools"] as const) { + test(`sparse terminal reconstruction keeps the restored identity for ${selectorKind}`, async () => { + const savedFetch = globalThis.fetch; + let outbound: Record | undefined; + const itemId = "fc_sparse"; + const callId = "call_sparse"; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)) as Record; + const choice = outbound.tool_choice as Record; + const wireName = selectorKind === "named" + ? choice.name as string + : ((choice.tools as Array<{ name: string }>)[0]!.name); + const item = { + type: "function_call", + id: itemId, + call_id: callId, + name: wireName, + arguments: "{}", + status: "completed", + }; + const upstream = [ + frame("response.output_item.done", { output_index: 0, item }), + frame("response.completed", { response: { id: "resp_sparse", status: "completed", output: [] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + try { + takeSpendHome(); + const toolChoice = selectorKind === "named" + ? { type: "function", name: original } + : { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: original }] }; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + stream: true, + input: "search", + tools: [{ type: "function", name: original, parameters: { type: "object" } }], + tool_choice: toolChoice, + }), + }), config, { model: "", provider: "", surface: "grok" }); + const outboundChoice = outbound?.tool_choice as Record; + const wireName = selectorKind === "named" + ? outboundChoice.name as string + : ((outboundChoice.tools as Array<{ name: string }>)[0]!.name); + expect(wireName).not.toBe(original); + expect((outbound?.tools as Array<{ name: string }>)[0]!.name).toBe(wireName); + + const terminal = ssePayloads(await response.text()) + .find(payload => payload.type === "response.completed"); + expect(terminal).toBeDefined(); + expect((terminal!.response as { output: unknown[] }).output).toEqual([expect.objectContaining({ + type: "function_call", + id: itemId, + call_id: callId, + name: original, + })]); + } finally { + globalThis.fetch = savedFetch; + } + }); + } + + test("sparse terminal reconstruction still refuses an unselected aliased tool", async () => { + const savedFetch = globalThis.fetch; + const selected = original; + const unselected = "mcp__plugin_android-emulator_android-emulator__android_install_app"; + globalThis.fetch = (async (_input, init) => { + const outbound = JSON.parse(String(init?.body)) as Record; + const selectedWire = (outbound.tool_choice as { name: string }).name; + const wireNames = (outbound.tools as Array<{ name: string }>).map(tool => tool.name); + const unselectedWire = wireNames.find(name => name !== selectedWire)!; + const item = { + type: "function_call", + id: "fc_unselected", + call_id: "call_unselected", + name: unselectedWire, + arguments: "{}", + status: "completed", + }; + const upstream = [ + frame("response.output_item.done", { output_index: 0, item }), + frame("response.completed", { response: { id: "resp_unselected", status: "completed", output: [] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + try { + takeSpendHome(); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + stream: true, + input: "search", + tools: [ + { type: "function", name: selected, parameters: { type: "object" } }, + { type: "function", name: unselected, parameters: { type: "object" } }, + ], + tool_choice: { type: "function", name: selected }, + }), + }), config, { model: "", provider: "", surface: "grok" }); + const terminal = ssePayloads(await response.text()) + .find(payload => payload.type === GROK_REFUSED_TERMINAL_EVENT_TYPE); + expect(terminal).toBeDefined(); + expect((terminal!.response as { output: unknown[] }).output).toEqual([]); + } finally { + globalThis.fetch = savedFetch; + } + }); + // #4410: the undeclared-tool guard reads `name` straight off this event, outside any // function_call item, so a hashed alias here would still look like an undeclared tool. test("streamed response.function_call_arguments.done restores the original name", async () => { diff --git a/tests/responses/responses-sparse-terminal-tool-scope.test.ts b/tests/responses/responses-sparse-terminal-tool-scope.test.ts index 4b8cd37becd..b1344ce73d3 100644 --- a/tests/responses/responses-sparse-terminal-tool-scope.test.ts +++ b/tests/responses/responses-sparse-terminal-tool-scope.test.ts @@ -5,6 +5,9 @@ import { GROK_FORBIDDEN_TOOL_CALL_REASON, GROK_REFUSED_TERMINAL_EVENT_TYPE, } from "../../src/server/grok-responses-snapshot-repair"; +import { rewriteRoutedCustomToolsForUpstream } from "../../src/responses/custom-tool-compat"; +import { rewriteRoutedNamespaceToolsForUpstream } from "../../src/responses/namespace-tool-compat"; +import type { RequestToolScopeCorrespondence } from "../../src/server/responses-request-tool-scope"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; function dataBlock(payload: unknown): string { @@ -56,10 +59,12 @@ const SPARSE_TERMINAL = { function relay( outboundBody: unknown, items: readonly { index: number; item: Record }[], + correspondence?: RequestToolScopeCorrespondence, ): { terminal: string; forwarded: string[] } { const rewrite = createGrokResponsesSparseTerminalBlockRewrite( createTestTranslatorBudget(), outboundBody, + correspondence, ); const forwarded: string[] = []; for (const { index, item } of items) { @@ -164,15 +169,99 @@ describe("Grok sparse terminal reconstruction honours the request's tool selecti test("a namespaced call matches the selector under either flattened spelling", () => { const namespaced: Record = { ...CALL_ITEM, name: "search", namespace: "docs" }; for (const selected of ["docs__search", "docs.search"]) { + const rewritten = rewriteRoutedNamespaceToolsForUpstream({ + model: "grok-4.6", + tools: [{ type: "namespace", name: "docs", tools: [{ type: "function", name: "search" }] }], + tool_choice: { type: "function", name: selected }, + }); + const { terminal } = relay( + rewritten.body, + ordered(namespaced), + { routedNamespaceToolAliases: rewritten.aliases }, + ); + expect(responseOf(terminal).output).toEqual([namespaced]); + } + }); + + test("a qualified selector never admits another namespace with the same basename", () => { + const rewritten = rewriteRoutedNamespaceToolsForUpstream({ + model: "grok-4.6", + tools: [ + { type: "namespace", name: "alpha", tools: [{ type: "function", name: "lookup" }] }, + { type: "namespace", name: "beta", tools: [{ type: "function", name: "lookup" }] }, + ], + tool_choice: { type: "function", name: "lookup", namespace: "alpha" }, + }); + const alpha = { ...CALL_ITEM, name: "lookup", namespace: "alpha" }; + const beta = { ...CALL_ITEM, name: "lookup", namespace: "beta" }; + const correspondence = { routedNamespaceToolAliases: rewritten.aliases }; + + expect(responseOf(relay(rewritten.body, ordered(alpha), correspondence).terminal).output) + .toEqual([alpha]); + expect(payloadOf(relay(rewritten.body, ordered(beta), correspondence).terminal).type) + .toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + }); + + test("same-named function and custom identities do not match by coincidence", () => { + const clientBody = { + model: "grok-4.6", + tools: [{ type: "function", name: "lookup" }, { type: "custom", name: "lookup" }], + tool_choice: { type: "function", name: "lookup" }, + }; + const converted = rewriteRoutedCustomToolsForUpstream(clientBody, false); + const custom = { + type: "custom_tool_call", + id: "ctc_1", + call_id: "call_1", + name: "lookup", + input: "query", + }; + + expect(payloadOf(relay(converted.body, ordered(custom), { + clientToolAuthorizationBody: clientBody, + convertedRoutedCustomToolNames: converted.names, + }).terminal).type) + .toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); + }); + + test("a request-verified custom-to-function conversion remains authorized", () => { + const clientBody = { + model: "grok-4.6", + tools: [{ type: "custom", name: "shell", description: "run", format: { type: "text" } }], + tool_choice: { type: "custom", name: "shell" }, + }; + const converted = rewriteRoutedCustomToolsForUpstream(clientBody, false); + const namespaced = rewriteRoutedNamespaceToolsForUpstream(converted.body, converted.names); + const custom = { + type: "custom_tool_call", + id: "ctc_1", + call_id: "call_1", + name: "shell", + input: "echo ok", + }; + + const { terminal } = relay(namespaced.body, ordered(custom, MESSAGE_ITEM), { + clientToolAuthorizationBody: clientBody, + routedNamespaceToolAliases: namespaced.aliases, + convertedRoutedCustomToolNames: converted.names, + }); + expect(responseOf(terminal).output).toEqual([custom, MESSAGE_ITEM]); + }); + + test("malformed narrowing selectors fail closed instead of becoming unrestricted", () => { + for (const toolChoice of [ + { type: "function", name: "apply_patch", namespace: 42 }, + { type: "allowed_tools", mode: "auto", tools: "apply_patch" }, + ]) { const { terminal } = relay( { model: "grok-4.6", - tools: [{ type: "namespace", name: "docs", tools: [{ type: "function", name: "search" }] }], - tool_choice: { type: "function", name: selected }, + tools: [{ type: "function", name: "apply_patch" }], + tool_choice: toolChoice, }, - ordered(namespaced), + ordered(CALL_ITEM), ); - expect(responseOf(terminal).output).toEqual([namespaced]); + expect(payloadOf(terminal).type).toBe(GROK_REFUSED_TERMINAL_EVENT_TYPE); } }); From 24b6a7f6f9a244e83fcb9321719e2e29b58ab349 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:44:01 +0900 Subject: [PATCH 66/87] test(server): stop paying for eight route snapshots in the #848 provenance test (#5402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #848 provenance test exceeded its own 20 s deadline on loaded shared macOS runners — 47.4 s on dev and 38.4 s on an unrelated PR — because it reads /api/system/memory eight times, and the route costs ~600 ms per read there. The timeout had already been widened once to reach 20 s; widening again would only hide the cost. The test's actual subject is one field's serialization given environment markers, not memory snapshot collection, and that matrix is already exercised directly against reportedBunRuntimeSource in tests/ci-workflows/bun-runtime.test.ts with assertions identical to the ones this test routed through the endpoint. What only this test can prove is the route wiring — system-routes.ts answers bunRuntimeSource from that function — and two reads prove it: the marker pair surfaces as bunRuntimeSource, and without the pair the field is absent. The explicit 20 s timeout goes back to the default along with the comment that had to justify it. No assertion is weakened; the per-case matrix lives on unchanged in the direct tests. --- tests/server/memory-watchdog.test.ts | 41 ++++++++++++---------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/tests/server/memory-watchdog.test.ts b/tests/server/memory-watchdog.test.ts index 918503456ce..dc1e0f45c97 100644 --- a/tests/server/memory-watchdog.test.ts +++ b/tests/server/memory-watchdog.test.ts @@ -300,36 +300,31 @@ describe("GET /api/system/memory", () => { return await res!.json() as { bunRuntimeSource?: unknown; bunRevision?: unknown }; }; try { - for (const source of ["override", "bundled", "process"]) { - process.env.OCX_BUN_RUNTIME_SOURCE = source; - // Source alone is not enough: the marker must name THIS executable. - expect((await read()).bunRuntimeSource).toBeUndefined(); - process.env.OCX_BUN_RUNTIME_PATH = process.execPath; - expect((await read()).bunRuntimeSource).toBe(source); - delete process.env.OCX_BUN_RUNTIME_PATH; - } - // A mismatched recorded path describes another binary — stay absent. - process.env.OCX_BUN_RUNTIME_SOURCE = "override"; - process.env.OCX_BUN_RUNTIME_PATH = "/usr/local/bin/definitely-not-this-bun"; - expect((await read()).bunRuntimeSource).toBeUndefined(); - delete process.env.OCX_BUN_RUNTIME_PATH; - delete process.env.OCX_BUN_RUNTIME_SOURCE; - // An unset or unrecognized marker must leave the field absent rather than - // shipping a value doctor would then have to distrust. - const unset = await read(); - expect(unset.bunRuntimeSource).toBeUndefined(); - expect(typeof unset.bunRevision).toBe("string"); + // The env-marker matrix itself — every allowlisted source, the pair contract, + // a mismatched recorded path, and absent or unrecognized markers — is exercised + // directly against the serialization target in + // tests/ci-workflows/bun-runtime.test.ts ("reportedBunRuntimeSource (#848 + // launch-time provenance)"), with assertions identical to the ones this test + // used to route through eight full memory snapshots (~600 ms each on the + // shared CI runners, over this test's own deadline twice in unrelated PRs). + // What only this test can still prove is the wiring: the route answers THIS + // field from THAT function. One read per wire shape is the whole cost of that. + process.env.OCX_BUN_RUNTIME_SOURCE = "bundled"; + process.env.OCX_BUN_RUNTIME_PATH = process.execPath; + const reported = await read(); + expect(reported.bunRuntimeSource).toBe("bundled"); + expect(typeof reported.bunRevision).toBe("string"); - process.env.OCX_BUN_RUNTIME_SOURCE = "system"; + // Without the marker pair the field is absent, never a guessed value. + delete process.env.OCX_BUN_RUNTIME_SOURCE; + delete process.env.OCX_BUN_RUNTIME_PATH; expect((await read()).bunRuntimeSource).toBeUndefined(); } finally { if (inherited === undefined) delete process.env.OCX_BUN_RUNTIME_SOURCE; else process.env.OCX_BUN_RUNTIME_SOURCE = inherited; delete process.env.OCX_BUN_RUNTIME_PATH; } - // The route costs ~600 ms per read on the shared CI runners, and this test makes - // eight of them — marginally over bun's 5 s default on a loaded box. - }, 20_000); + }); test("GET system memory includes privacy-safe appOwnedBytes scalars", async () => { registerDefaultAppOwnedMemoryStores(); From 2cc11b780a95e11acc9a7df575cb4a38b858201a Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:46:08 +0900 Subject: [PATCH 67/87] fix(server): preserve replay refusal on routed Claude Messages (#5396) * test(server): cover Messages replay refusal parity * fix(server): carry replay refusal through Messages * docs(server): scope Messages refusal to routed requests * docs(server): distinguish Messages error envelope --- .../docs/reference/configuration/server.md | 6 ++- src/server/claude-messages.ts | 54 +++++++++++++------ structure/transports/responses.md | 6 +-- tests/server/replay-refusal-parity.test.ts | 14 +++-- 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 92aae00980f..cf317ae8e0c 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -66,8 +66,10 @@ deliberate: a 5xx here is an instruction to most clients, including Codex, to se turn again, which is the duplicate the refusal exists to prevent. No `Retry-After` is attached, and the response carries `x-should-retry: false`, which the official OpenAI and Anthropic SDKs read before their own status rules — without it those clients retry a 429 on -their own schedule and resend the turn anyway. The answer is identical on `/v1/responses` and -on `/v1/chat/completions`, whether the request is forwarded natively or translated. The proxy +their own schedule and resend the turn anyway. The same replay-refusal behavior applies on +`/v1/responses` and `/v1/chat/completions`, whether the request is forwarded natively or translated. +Routed `/v1/messages` requests translate through Responses and project the same status, code, and +retry headers into an Anthropic-shaped error envelope. The proxy performs no key rotation, account failover or same-target replay on it, nor does it record the refusal as rate-limit or quota evidence against the credential it was holding. Tool-call side requests such as vision and web search are replayed normally, because repeating them cannot diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index b27ec73c91e..93c9318d4b1 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -24,7 +24,14 @@ import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; -import { isTransientUpstreamStatus } from "../lib/upstream-retry"; +import { + applyReplayRefusalClientHeaders, + carryReplayRefusal, + isReplayRefusalResponse, + isTransientUpstreamStatus, + REPLAY_REFUSED_STATUS, + UPSTREAM_RESET_REPLAY_REFUSED_CODE, +} from "../lib/upstream-retry"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { anthropicErrorBody, @@ -979,6 +986,9 @@ async function handleClaudeMessagesWithBudget( const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; if (!response.ok) { + // Read the shared provenance verdict before consuming and re-wrapping the body. A refusal + // and an ordinary provider rate limit are both 429, so the status cannot distinguish them. + const replayRefusal = isReplayRefusalResponse(response); // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status. let message = `upstream error (${response.status})`; try { @@ -993,14 +1003,16 @@ async function handleClaudeMessagesWithBudget( } } catch { /* keep fallback message */ } const upstreamRetryAfter = response.headers.get("retry-after"); - const retryAfter = resolveClientRetryAfter({ - status: response.status, - message, - upstreamRetryAfter, - }) - // Instant-retry "0" is a valid client directive but rejected by cooldown parsers. - // Preserve it so it still wins over the transient "2" fallback (claude-529 mapping). - ?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined); + const retryAfter = replayRefusal + ? undefined + : resolveClientRetryAfter({ + status: response.status, + message, + upstreamRetryAfter, + }) + // Instant-retry "0" is a valid client directive but rejected by cooldown parsers. + // Preserve it so it still wins over the transient "2" fallback (claude-529 mapping). + ?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined); // Transient upstream 5xx (already retried pre-stream, 010): reclassify as Anthropic // 529 overloaded_error so the Claude Code client applies its built-in backoff retry // instead of dying on a fatal api_error (260716 sol-builder incident). The request @@ -1011,16 +1023,24 @@ async function handleClaudeMessagesWithBudget( const nativeMainFence = response.status === 503 && upstreamRetryAfter?.trim() === "1" && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE; - const transient = !nativeMainFence && isTransientUpstreamStatus(response.status); - const outStatus = nativeMainFence ? 503 : transient ? 529 : response.status; - const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), { + const transient = !replayRefusal && !nativeMainFence && isTransientUpstreamStatus(response.status); + const outStatus = replayRefusal + ? REPLAY_REFUSED_STATUS + : nativeMainFence ? 503 : transient ? 529 : response.status; + const outHeaders = new Headers({ "Content-Type": "application/json" }); + if (retryAfter) outHeaders.set("Retry-After", retryAfter); + else if (transient) outHeaders.set("Retry-After", "2"); + if (replayRefusal) applyReplayRefusalClientHeaders(outHeaders); + const out = new Response(JSON.stringify(anthropicErrorBody( + outStatus, + message, + undefined, + replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined, + )), { status: outStatus, - headers: { - "Content-Type": "application/json", - ...(retryAfter ? { "Retry-After": retryAfter } : (transient ? { "Retry-After": "2" } : {})), - }, + headers: outHeaders, }); - return out; + return carryReplayRefusal(response, out); } const contentType = response.headers.get("content-type") ?? ""; diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 56718320172..b1e28958fa1 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -1411,8 +1411,8 @@ rate limit share. The failed-envelope path in the same function restates it too, arriving as `status: "failed"` is not reported as the 502 a Codex client retries four times. Because a re-wrap is where the in-process marker is lost, `retainReplayRefusal` and `carryReplayRefusal` in `src/lib/upstream-retry.ts` are what each formatter calls: -`src/bridge/errors.ts`, `src/server/responses/passthrough-error.ts`, both Chat wrappers, and the -deferred-logging re-wrap in `src/server/relay.ts`. +`src/bridge/errors.ts`, `src/server/responses/passthrough-error.ts`, both Chat wrappers, the +routed Claude Messages wrapper, and the deferred-logging re-wrap in `src/server/relay.ts`. **Dropping `Retry-After` is necessary and not sufficient.** The status stays 429 because Codex stops there and a 5xx invites four more sends, but the Stainless-generated clients — `openai` @@ -1422,7 +1422,7 @@ proxy. Every surface therefore also emits `x-should-retry: false`, the one signa read before that table. The refusal is the only code that gets it: the WebSocket post-send verdicts are genuine upstream observations and keep their existing 502/504 contract. The acceptance evidence is a count, not a shape — `tests/server/replay-refusal-parity.test.ts` runs -the proxy over a socket, drives all three surfaces with a client that implements the published +the proxy over a socket, drives all four surfaces with a client that implements the published SDK rule, and asserts one physical upstream send per logical request, with a rate-limit control that shows the same client resending. diff --git a/tests/server/replay-refusal-parity.test.ts b/tests/server/replay-refusal-parity.test.ts index 9b8af2cf14c..0c6975ccb13 100644 --- a/tests/server/replay-refusal-parity.test.ts +++ b/tests/server/replay-refusal-parity.test.ts @@ -10,6 +10,7 @@ import { REPLAY_REFUSED_STATUS, UPSTREAM_RESET_REPLAY_REFUSED_CODE, } from "../../src/lib/upstream-retry"; +import { DEFAULT_RETRYABLE_429_RETRY_AFTER_SEC } from "../../src/lib/retry-after"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import type { OcxConfig } from "../../src/types"; @@ -21,7 +22,7 @@ import type { OcxConfig } from "../../src/types"; * resends -- the proxy answered correctly and the duplicate inference happened anyway. * * So these cases run the proxy over a real socket, count the sends at the upstream boundary, - * and drive it with a client that retries the way the published SDKs do. The three surfaces + * and drive it with a client that retries the way the published SDKs do. The four surfaces * are asserted against one expectation because a client cannot tell them apart: it sent one * turn and the turn may already have executed, whichever endpoint carried it. */ @@ -132,6 +133,7 @@ function parityConfig(): OcxConfig { } const CHAT_TURN = { messages: [{ role: "user", content: "ping" }] }; +const CLAUDE_TURN = { max_tokens: 64, messages: [{ role: "user", content: "ping" }] }; const RESPONSES_TURN = { input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }] }; test("every HTTP surface answers an ambiguous reset with one send and no client resend", async () => { @@ -142,6 +144,7 @@ test("every HTTP surface answers an ambiguous reset with one send and no client { name: "native Chat", path: "/v1/chat/completions", body: { model: "native/model", ...CHAT_TURN } }, { name: "translated Chat", path: "/v1/chat/completions", body: { model: "bridged/model", ...CHAT_TURN } }, { name: "Responses", path: "/v1/responses", body: { model: "bridged/model", ...RESPONSES_TURN } }, + { name: "routed Claude Messages", path: "/v1/messages", body: { model: "bridged/model", ...CLAUDE_TURN } }, ]; try { for (const surface of surfaces) { @@ -170,21 +173,22 @@ test("every HTTP surface answers an ambiguous reset with one send and no client /** * The control that keeps the assertion above honest. A client double that never resends would * pin "one send" for any answer at all, so the same client has to be shown resending a real - * rate limit -- the answer a refusal was indistinguishable from on the translated surface. + * rate limit -- the answer a refusal was indistinguishable from on the translated surfaces. */ test("the same client still resends an ordinary upstream rate limit", async () => { saveConfig(parityConfig()); const sends = countingUpstream(() => new Response( JSON.stringify({ error: { message: "Too many requests", type: "rate_limit_error" } }), - { status: 429, headers: { "content-type": "application/json", "retry-after": "0" } }, + { status: 429, headers: { "content-type": "application/json" } }, )); const server = startServer(0); try { const { response, attempts } = await sendWithClientRetries( - new URL("/v1/chat/completions", server.url), - { model: "native/model", ...CHAT_TURN }, + new URL("/v1/messages", server.url), + { model: "bridged/model", ...CLAUDE_TURN }, ); expect(response.status).toBe(429); + expect(response.headers.get("Retry-After")).toBe(String(DEFAULT_RETRYABLE_429_RETRY_AFTER_SEC)); expect(response.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER)).toBeNull(); expect(attempts).toBe(3); expect(sends()).toBeGreaterThan(1); From d7ab63a4d90ad3bf266692ee4bfa08d6d4003368 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:49:37 +0900 Subject: [PATCH 68/87] feat(desktop): dual Linux updater targets and the installed-artifact release gate (#5391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): publish the deb bundle as a second Linux updater target The updater manifest resolved every Linux install to the AppImage payload, so a dpkg install could detect an update but never apply it: the updater validates the downloaded bytes as a real .deb before installing and rejects the AppImage artifact. The AppImage keeps the plugin default linux-x86_64 key so AppImage installs from earlier releases keep resolving their updates. The deb publishes under the explicit linux-x86_64-deb key, which the shell selects from the bundle type embedded at packaging time in the next commit. * feat(desktop): select the Linux updater target from the bundle type A deb install cannot apply the AppImage payload: the pinned updater validates the downloaded bytes as a real .deb and installs through package-manager elevation, so resolving the default linux-x86_64 key gives it an artifact it must reject. The updater check now resolves its manifest key through UpdaterBuilder::target. On Linux, a bundle patched as Deb at packaging time (tauri-utils bundle_type, embedded by the bundler's binary patching) selects linux-x86_64-deb; AppImage and unbundled builds keep the updater's default key, which is also what installs from releases before the deb target existed already resolve. * fix(desktop): build with a Tauri CLI that embeds the bundle type updater.rs selects the deb updater target from tauri_utils::platform::bundle_type(), which reads a marker the tauri-bundler patches into the binary at packaging time. The pinned @tauri-apps/cli 2.5.0 ships bundler 2.4.0, which has no patching code: every packaged artifact would report "unknown", the deb branch would never fire, and a deb install would keep resolving the AppImage payload it cannot apply. The unit tests inject BundleType values, so they cannot see this packaging failure — the gap was caught by audit. tauri-cli 2.11.1 (bundler 2.9.1) patches the marker per package type, signs after patching, and restores the unpatched binary between formats (crates/tauri-bundler/src/bundle.rs at tag tauri-cli-v2.11.1). It matches the runtime line already pinned in Cargo.toml (tauri 2.11.6, tauri-utils 2.9.3). desktop/ carries no tracked lockfile, so the bump is package.json only; a source-oracle test pins the floor at the first CLI whose bundler patches (2.7.0). * feat(desktop): add the installed-artifact gate drivers The release gate from D9 part two: a contract engine plus per-platform adapters that install the real artifact, launch it against a staged npm runtime on a non-default port, and drive the ownership contract — drain with registration preserved, one consent-generation increment, healthz identifying the bundled sidecar on the preserved home and port, close and quit gestures leaving both pids alive, and tray Quit letting an in-flight request finish before both processes end. Linux gates carry the update phase for both formats (R3): AppImage in place with no elevation, deb asking for authorization only after Install with cancel preserving the old version. GUI automation that an OS cannot reach (the in-page consent dialog, Windows and Linux tray clicks) is supplied by the operator as override commands; the engine fails those phases with diagnostics rather than guessing. Cleanup is strict rollback and its failure fails the report. * ci: add the self-hosted installed-artifact gate workflow Dispatch-only workflow that runs the gate drivers on one maintainer- registered GUI machine per platform: macOS, Windows, and a Linux matrix over deb and AppImage. Each job downloads the real release artifacts, stages the older release as the npm runtime, runs the ownership and update contract, and uploads the JSON report whether the gate passed or failed. Registering the runners, supplying the machine-specific tray and consent automation commands, and wiring this gate into release.yml publication are maintainer actions outside this change. --- .github/workflows/desktop-installed-gate.yml | 277 +++++ desktop/package.json | 2 +- desktop/scripts/installed-gate-platforms.ts | 407 +++++++ desktop/scripts/installed-gate.ts | 1002 +++++++++++++++++ desktop/scripts/updater-manifest.ts | 6 + desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/updater.rs | 58 +- scripts/test-layout/layout.json | 1 + .../installed-gate-drivers.test.ts | 552 +++++++++ .../release-desktop-scripts.test.ts | 36 +- tests/fixtures/test-layout-expected.json | 1 + 12 files changed, 2339 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/desktop-installed-gate.yml create mode 100644 desktop/scripts/installed-gate-platforms.ts create mode 100644 desktop/scripts/installed-gate.ts create mode 100644 tests/ci-workflows/installed-gate-drivers.test.ts diff --git a/.github/workflows/desktop-installed-gate.yml b/.github/workflows/desktop-installed-gate.yml new file mode 100644 index 00000000000..8782cd84623 --- /dev/null +++ b/.github/workflows/desktop-installed-gate.yml @@ -0,0 +1,277 @@ +name: desktop installed-artifact gate + +# D9 part two: install the real artifact on a machine per platform, launch it against a +# staged npm runtime, and exercise the ownership contract — takeover, the gestures that +# must leave the runtime alive, tray Quit draining an in-flight request, and on Linux +# both update paths (R3). Runs only on maintainer-registered self-hosted GUI machines; +# publication wiring into release.yml is a separate change. + +on: + workflow_dispatch: + inputs: + version: + description: Release version whose desktop artifacts the gate installs + required: true + type: string + from-version: + description: Older release used for the staged npm runtime and the Linux update phases + required: true + type: string + # Hook inputs are FILE NAMES, never command text. The runner's operator installs + # audited executables in a hooks directory (vars.OPENCODEX_GATE_HOOKS_DIR) and a + # dispatch picks among them by name; the gate executes the file directly, so this + # workflow can never become an arbitrary-shell surface on a persistent runner. + consent-hook: + description: Name of the runner hook that answers the takeover consent prompt + required: false + type: string + tray-click-hook: + description: Name of the runner hook that left-clicks the tray icon + required: false + type: string + tray-quit-hook: + description: Name of the runner hook that opens the tray menu and chooses Quit + required: false + type: string + tray-check-hook: + description: Name of the runner hook that chooses Check for Updates in the tray + required: false + type: string + tray-install-hook: + description: Name of the runner hook that chooses Install update in the tray + required: false + type: string + elevate-accept-hook: + description: Name of the runner hook that answers the deb update's elevation prompt (drives the accept path) + required: false + type: string + +permissions: + contents: read + +concurrency: + group: desktop-installed-gate-${{ inputs.version }} + cancel-in-progress: false + +jobs: + macos: + runs-on: [self-hosted, opencodex-gate-macos] + timeout-minutes: 60 + # Required-review environment: no run reaches the GUI runner without a maintainer + # approval, and the checkout below pins the driver to the protected dev branch, so a + # dispatched ref cannot smuggle modified gate code onto the machine. + environment: opencodex-desktop-gate + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: dev + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the release artifact + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + run: | + mkdir -p "$GATE_ARTIFACTS" + gh release download "v${RELEASE_VERSION}" \ + --pattern "OpenCodex-${RELEASE_VERSION}-macos.dmg" \ + --dir "$GATE_ARTIFACTS" \ + --clobber + + - name: Run the installed-artifact gate + env: + RELEASE_VERSION: ${{ inputs.version }} + FROM_VERSION: ${{ inputs.from-version }} + CONSENT_HOOK: ${{ inputs.consent-hook }} + TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} + TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} + GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + GATE_WORK: ${{ runner.temp }}/installed-gate + GATE_REPORT: ${{ runner.temp }}/installed-gate-report.json + run: | + set -euo pipefail + args=( + --platform macos --format dmg + --artifact "$GATE_ARTIFACTS/OpenCodex-${RELEASE_VERSION}-macos.dmg" + --work-dir "$GATE_WORK" + --to-version "$RELEASE_VERSION" + --from-version "$FROM_VERSION" + --report "$GATE_REPORT" + ) + if [ -n "$GATE_HOOKS_DIR" ]; then args+=(--hooks-dir "$GATE_HOOKS_DIR"); fi + for pair in "consent-hook:CONSENT_HOOK" "tray-click-hook:TRAY_CLICK_HOOK" "tray-quit-hook:TRAY_QUIT_HOOK"; do + name="${pair%%:*}"; env_name="${pair##*:}" + value="${!env_name}" + if [ -n "$value" ]; then args+=("--${name}" "$value"); fi + done + bun desktop/scripts/installed-gate.ts "${args[@]}" + + - name: Upload the gate report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: installed-gate-report-macos + path: ${{ runner.temp }}/installed-gate-report.json + if-no-files-found: error + + windows: + runs-on: [self-hosted, opencodex-gate-windows] + timeout-minutes: 60 + environment: opencodex-desktop-gate + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: dev + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the release artifact + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + run: | + mkdir -p "$GATE_ARTIFACTS" + gh release download "v${RELEASE_VERSION}" \ + --pattern "OpenCodex-${RELEASE_VERSION}-windows-x64.msi" \ + --dir "$GATE_ARTIFACTS" \ + --clobber + + - name: Run the installed-artifact gate + env: + RELEASE_VERSION: ${{ inputs.version }} + FROM_VERSION: ${{ inputs.from-version }} + CONSENT_HOOK: ${{ inputs.consent-hook }} + TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} + TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} + GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + GATE_WORK: ${{ runner.temp }}/installed-gate + GATE_REPORT: ${{ runner.temp }}/installed-gate-report.json + run: | + set -euo pipefail + args=( + --platform windows --format msi + --artifact "$GATE_ARTIFACTS/OpenCodex-${RELEASE_VERSION}-windows-x64.msi" + --work-dir "$GATE_WORK" + --to-version "$RELEASE_VERSION" + --from-version "$FROM_VERSION" + --report "$GATE_REPORT" + ) + if [ -n "$GATE_HOOKS_DIR" ]; then args+=(--hooks-dir "$GATE_HOOKS_DIR"); fi + for pair in "consent-hook:CONSENT_HOOK" "tray-click-hook:TRAY_CLICK_HOOK" "tray-quit-hook:TRAY_QUIT_HOOK"; do + name="${pair%%:*}"; env_name="${pair##*:}" + value="${!env_name}" + if [ -n "$value" ]; then args+=("--${name}" "$value"); fi + done + bun desktop/scripts/installed-gate.ts "${args[@]}" + + - name: Upload the gate report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: installed-gate-report-windows + path: ${{ runner.temp }}/installed-gate-report.json + if-no-files-found: error + + linux: + runs-on: [self-hosted, opencodex-gate-linux] + timeout-minutes: 60 + environment: opencodex-desktop-gate + strategy: + fail-fast: false + matrix: + format: [deb, appimage] + include: + - format: deb + suffix: linux-amd64.deb + - format: appimage + suffix: linux-x86_64.AppImage + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: dev + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the release artifacts + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + FROM_VERSION: ${{ inputs.from-version }} + ARTIFACT_SUFFIX: ${{ matrix.suffix }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + run: | + mkdir -p "$GATE_ARTIFACTS" + gh release download "v${RELEASE_VERSION}" \ + --pattern "OpenCodex-${RELEASE_VERSION}-${ARTIFACT_SUFFIX}" \ + --dir "$GATE_ARTIFACTS" \ + --clobber + gh release download "v${FROM_VERSION}" \ + --pattern "OpenCodex-${FROM_VERSION}-${ARTIFACT_SUFFIX}" \ + --dir "$GATE_ARTIFACTS" \ + --clobber + + - name: Run the installed-artifact gate + env: + RELEASE_VERSION: ${{ inputs.version }} + FROM_VERSION: ${{ inputs.from-version }} + GATE_FORMAT: ${{ matrix.format }} + ARTIFACT_SUFFIX: ${{ matrix.suffix }} + CONSENT_HOOK: ${{ inputs.consent-hook }} + TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} + TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} + TRAY_CHECK_HOOK: ${{ inputs.tray-check-hook }} + TRAY_INSTALL_HOOK: ${{ inputs.tray-install-hook }} + ELEVATE_ACCEPT_HOOK: ${{ inputs.elevate-accept-hook }} + GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + GATE_WORK: ${{ runner.temp }}/installed-gate + GATE_REPORT: ${{ runner.temp }}/installed-gate-report.json + run: | + set -euo pipefail + args=( + --platform linux --format "$GATE_FORMAT" + --artifact "$GATE_ARTIFACTS/OpenCodex-${RELEASE_VERSION}-${ARTIFACT_SUFFIX}" + --older-artifact "$GATE_ARTIFACTS/OpenCodex-${FROM_VERSION}-${ARTIFACT_SUFFIX}" + --work-dir "$GATE_WORK" + --to-version "$RELEASE_VERSION" + --from-version "$FROM_VERSION" + --report "$GATE_REPORT" + ) + if [ -n "$GATE_HOOKS_DIR" ]; then args+=(--hooks-dir "$GATE_HOOKS_DIR"); fi + for pair in "consent-hook:CONSENT_HOOK" "tray-click-hook:TRAY_CLICK_HOOK" "tray-quit-hook:TRAY_QUIT_HOOK" "tray-check-hook:TRAY_CHECK_HOOK" "tray-install-hook:TRAY_INSTALL_HOOK" "elevate-accept-hook:ELEVATE_ACCEPT_HOOK"; do + name="${pair%%:*}"; env_name="${pair##*:}" + value="${!env_name}" + if [ -n "$value" ]; then args+=("--${name}" "$value"); fi + done + bun desktop/scripts/installed-gate.ts "${args[@]}" + + - name: Upload the gate report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: installed-gate-report-linux-${{ matrix.format }} + path: ${{ runner.temp }}/installed-gate-report.json + if-no-files-found: error diff --git a/desktop/package.json b/desktop/package.json index 144f8540644..5966c9235f2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,6 +11,6 @@ "prepare-widget": "bash scripts/build-widget.sh" }, "devDependencies": { - "@tauri-apps/cli": "2.5.0" + "@tauri-apps/cli": "2.11.1" } } diff --git a/desktop/scripts/installed-gate-platforms.ts b/desktop/scripts/installed-gate-platforms.ts new file mode 100644 index 00000000000..0fd10bf95d1 --- /dev/null +++ b/desktop/scripts/installed-gate-platforms.ts @@ -0,0 +1,407 @@ +/** + * Platform adapters for the installed-artifact gate (D9, part two). + * + * Adapters turn platform actions into command specs the gate engine runs and records; + * they never hardcode machine detail. Artifact paths, homes and ports arrive as + * arguments. GUI automation the OS cannot reach (the in-page consent dialog, tray + * clicks on some desktops) is supplied by the operator as pre-installed hook files, + * never as dispatch-provided command text — a persistent self-hosted runner must not + * become an arbitrary-execution surface. + * + * The external commands each adapter needs are declared in dependencies() so a runner + * can be audited for readiness before an artifact is ever installed on it. + */ + +export type GatePlatform = "macos" | "windows" | "linux"; +export type GateFormat = "dmg" | "msi" | "deb" | "appimage"; + +import { join } from "node:path"; + +export interface CommandSpec { + file: string; + args: string[]; +} + +export interface ProcessEvidence { + ok: boolean; + exitCode: number | null; + stdout: string; + stderr: string; +} + +export interface InstallResult { + appBinary: string; + packageName?: string; + /** Path scope that identifies THIS install's processes (install dir or binary path). */ + scope: string; + evidence: Record; +} + +export interface PlatformAdapter { + platform: GatePlatform; + /** Every external command this adapter shells out to; the engine preflights them. */ + dependencies(): string[]; + /** The staged npm ocx launcher inside an npm --prefix install. */ + npmLauncher(prefix: string): string; + /** Registers and starts the staged npm runtime as a managed service. */ + serviceInstall(launcher: string): CommandSpec; + serviceUninstall(launcher: string): CommandSpec; + /** + * Three-state registration answer: an existing registration is "present", a clean + * not-found is "absent", and any probe failure that cannot be told apart is + * "unknown" — the engine refuses to mutate on unknown. + */ + registrationState(): Promise<"present" | "absent" | "unknown">; + /** + * On-disk registration files, relative to the runner account's home. A registration + * that is unloaded, disabled or not yet loaded leaves these behind, and the manager + * probes above miss exactly those states. + */ + registrationFiles(): string[]; + /** + * Read-only probe for a dormant installation (MSI registry entry, dpkg record) the + * gate must refuse to overwrite. Null where installs land inside the work dir. + */ + existingInstallation(format: GateFormat, packageName?: string): CommandSpec | null; + /** Installs the real artifact; returns the app executable path. */ + installArtifact(artifact: string, workDir: string, format: GateFormat): Promise; + /** Drives the installed app's window-close gesture. */ + closeGesture(): CommandSpec; + /** Drives the installed app's OS-quit gesture (Cmd+Q, Alt+F4). */ + quitGesture(): CommandSpec; + /** Left-clicks the tray icon (the shell shows the dashboard window), or null. */ + trayClick(): CommandSpec | null; + /** Opens the tray menu and chooses Quit (the drain-then-exit path), or null. */ + trayQuit(): CommandSpec | null; + /** Chooses Check for Updates in the tray menu, or null. */ + trayCheck(): CommandSpec | null; + /** Chooses the enabled Install update item in the tray menu, or null. */ + trayInstall(): CommandSpec | null; + /** Exits zero only when the app currently has a visible window. */ + windowVisible(): CommandSpec; + /** Installed package version probe, where the platform has one (deb), or null. */ + installedVersion(format: GateFormat, packageName?: string): CommandSpec | null; + /** Dismisses exactly the authorization prompts the gate sighted, by pid, or null. */ + cancelElevation(pids: number[]): CommandSpec | null; + /** + * Lists pids of any elevation prompt surface — pkexec, and the zenity/kdialog + * password dialogs the updater plugin falls back to after a pkexec cancel. + * Null where the platform has no package-manager elevation (macOS, Windows). + */ + elevationProbe(): CommandSpec | null; + /** Lists pids whose executable lives under the given install scope. */ + appProcessProbe(scope: string): CommandSpec; + /** Lists pids of ANY installed copy of the app — the preflight's broad probe. */ + appNameProbe(): CommandSpec; + /** Lists direct child pids of the given process. */ + childPids(pid: number): CommandSpec; + /** Removes what installArtifact placed on the machine. */ + uninstall(artifact: string, workDir: string, format: GateFormat, packageName?: string): CommandSpec[]; +} + +export interface AdapterRuntime { + run(spec: CommandSpec): Promise; + mkdir(path: string): void; + fileExists(path: string): boolean; + homeDir(): string; +} + +function requireOk(step: string, result: ProcessEvidence): void { + if (!result.ok) { + throw new Error(`${step} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`); + } +} + +/** macOS: dmg install, AppleScript gestures scoped to the OpenCodex process, launchd. */ +export function macosAdapter(runtime: AdapterRuntime): PlatformAdapter { + const run = runtime.run.bind(runtime); + return { + platform: "macos", + dependencies: () => ["hdiutil", "osascript", "pgrep", "launchctl", "cp", "rm", "/usr/libexec/PlistBuddy"], + npmLauncher: prefix => `${prefix}/node_modules/.bin/ocx`, + serviceInstall: launcher => ({ file: launcher, args: ["service", "install"] }), + serviceUninstall: launcher => ({ file: launcher, args: ["service", "uninstall"] }), + registrationState: async () => { + if (runtime.fileExists(join(runtime.homeDir(), "Library/LaunchAgents/com.opencodex.proxy.plist"))) return "present"; + const probe = await run({ file: "launchctl", args: ["list", "com.opencodex.proxy"] }); + if (probe.ok) return "present"; + // "Could not find service" is a clean absence; anything else is unknowable here. + return /could not find/i.test(probe.stderr) ? "absent" : "unknown"; + }, + registrationFiles: () => ["Library/LaunchAgents/com.opencodex.proxy.plist"], + // A dmg install lands inside the gate's work dir; there is no system-level record. + existingInstallation: () => null, + async installArtifact(artifact, workDir) { + const mount = `${workDir}/dmg-mount`; + const apps = `${workDir}/Applications`; + runtime.mkdir(mount); + runtime.mkdir(apps); + requireOk("dmg attach", await run({ file: "hdiutil", args: ["attach", artifact, "-mountpoint", mount, "-nobrowse", "-readonly"] })); + try { + requireOk("app copy", await run({ file: "cp", args: ["-R", `${mount}/OpenCodex.app`, `${apps}/`] })); + } finally { + await run({ file: "hdiutil", args: ["detach", mount] }); + } + // The executable name is the bundle's own declaration, not a guess: a rename in + // packaging lands here without a driver change (#5351 removed this hardcode once). + const plist = await run({ + file: "/usr/libexec/PlistBuddy", + args: ["-c", "Print :CFBundleExecutable", `${apps}/OpenCodex.app/Contents/Info.plist`], + }); + requireOk("read CFBundleExecutable", plist); + const executable = plist.stdout.trim(); + return { + appBinary: `${apps}/OpenCodex.app/Contents/MacOS/${executable}`, + scope: apps, + evidence: { mounted: mount, copiedTo: apps, executable }, + }; + }, + closeGesture: () => appleScript( + 'tell application "OpenCodex" to activate', + 'tell application "System Events" to keystroke "w" using command down', + ), + quitGesture: () => appleScript( + 'tell application "OpenCodex" to activate', + 'tell application "System Events" to keystroke "q" using command down', + ), + // Menu bar items belong to their owning process; clicking by global index can hit an + // unrelated tray, so every tray action is scoped to the OpenCodex process. + trayClick: () => appleScript( + 'tell application "System Events" to tell process "OpenCodex" to click menu bar item 1 of menu bar 2', + ), + trayQuit: () => appleScript( + 'tell application "System Events" to tell process "OpenCodex" to click menu bar item 1 of menu bar 2', + 'tell application "System Events" to tell process "OpenCodex" to click menu item "Quit" of menu 1 of menu bar item 1 of menu bar 2', + ), + trayCheck: () => appleScript( + 'tell application "System Events" to tell process "OpenCodex" to click menu bar item 1 of menu bar 2', + 'tell application "System Events" to tell process "OpenCodex" to click menu item "Check for Updates…" of menu 1 of menu bar item 1 of menu bar 2', + ), + trayInstall: () => appleScript( + 'tell application "System Events" to tell process "OpenCodex" to click menu bar item 1 of menu bar 2', + 'tell application "System Events" to tell process "OpenCodex" to click (first menu item of menu 1 of menu bar item 1 of menu bar 2 whose name starts with "Install update")', + ), + windowVisible: () => appleScript('tell application "System Events" to count (windows of process "OpenCodex")'), + installedVersion: () => null, + cancelElevation: () => null, + elevationProbe: () => null, + appProcessProbe: scope => ({ file: "pgrep", args: ["-f", scope] }), + appNameProbe: () => ({ file: "pgrep", args: ["-f", "OpenCodex.app/Contents/MacOS"] }), + childPids: pid => ({ file: "pgrep", args: ["-P", String(pid)] }), + uninstall: (_artifact, workDir) => [{ file: "rm", args: ["-rf", `${workDir}/Applications/OpenCodex.app`] }], + }; +} + +/** Windows: msi install, PowerShell gestures, Task Scheduler registration. */ +export function windowsAdapter(runtime: AdapterRuntime): PlatformAdapter { + const run = runtime.run.bind(runtime); + return { + platform: "windows", + dependencies: () => ["msiexec", "powershell", "schtasks", "sc"], + npmLauncher: prefix => `${prefix}\\node_modules\\.bin\\ocx.cmd`, + serviceInstall: launcher => ({ file: launcher, args: ["service", "install"] }), + serviceUninstall: launcher => ({ file: launcher, args: ["service", "uninstall"] }), + // Task Scheduler is the default backend; the native backend registers a WinSW + // service instead, and both count as an existing registration. + registrationState: async () => { + const task = await run({ file: "schtasks", args: ["/Query", "/TN", "opencodex-proxy"] }); + if (task.ok) return "present"; + const taskAbsent = /cannot find|does not exist/i.test(task.stderr + task.stdout); + const service = await run({ file: "sc.exe", args: ["query", "opencodex-proxy-native"] }); + if (service.ok) return "present"; + const serviceAbsent = /does not exist/i.test(service.stderr + service.stdout); + if (taskAbsent && serviceAbsent) return "absent"; + return "unknown"; + }, + registrationFiles: () => [], + // A dormant MSI install shows up in the uninstall registry before any process runs. + existingInstallation: format => + format === "msi" + ? { + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$key = Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' | Where-Object { $_.DisplayName -eq 'OpenCodex' }; if ($key) { exit 0 } else { exit 1 }", + ], + } + : null, + async installArtifact(artifact, workDir) { + requireOk( + "msi install", + await run({ file: "msiexec", args: ["/i", artifact, "/qn", "/norestart", "/l*v", `${workDir}\\msi-install.log`] }), + ); + const locate = await run({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$key = Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' | Where-Object { $_.DisplayName -eq 'OpenCodex' } | Select-Object -First 1; " + + "$dir = $key.InstallLocation; " + + "@(\"opencodex-desktop.exe\", \"opencodex.exe\") | ForEach-Object { $p = Join-Path $dir $_; if (Test-Path $p) { Write-Output $dir; Write-Output $p; break } }", + ], + }); + const locateLines = locate.stdout.trim().split(/\r?\n/); + const installDir = locateLines[0] ?? ""; + const appBinary = locateLines[1] ?? ""; + if (!appBinary) { + // The MSI may already be installed; a discovery failure must not strand it. + await run({ file: "msiexec", args: ["/x", artifact, "/qn", "/norestart"] }); + throw new Error("MSI installed but no OpenCodex executable was found under its InstallLocation"); + } + return { appBinary, scope: installDir, evidence: { installLog: `${workDir}\\msi-install.log`, located: appBinary } }; + }, + closeGesture: () => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$p = Get-Process opencodex-desktop -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1; " + + "if (-not $p) { exit 1 }; " + + "$sig = '[DllImport(\"user32.dll\")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);'; " + + "Add-Type -MemberDefinition $sig -Name U32 -Namespace W; [W.U32]::PostMessage($p.MainWindowHandle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null", + ], + }), + quitGesture: () => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$p = Get-Process opencodex-desktop -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1; " + + "if (-not $p) { exit 1 }; " + + "$shell = New-Object -ComObject WScript.Shell; $shell.AppActivate($p.Id) | Out-Null; $shell.SendKeys('%{F4}')", + ], + }), + // The Windows tray lives in the shell's notification area; a pre-installed runner + // hook (UIA) drives it. See --hooks-dir in installed-gate.ts. + trayClick: () => null, + trayQuit: () => null, + trayCheck: () => null, + trayInstall: () => null, + windowVisible: () => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$p = Get-Process opencodex-desktop -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 }; if ($p) { exit 0 } else { exit 1 }", + ], + }), + installedVersion: () => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' | Where-Object { $_.DisplayName -eq 'OpenCodex' } | Select-Object -First 1).DisplayVersion", + ], + }), + cancelElevation: () => null, + elevationProbe: () => null, + appProcessProbe: scope => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ExecutablePath LIKE '${scope.replace(/%/g, "")}%'").ProcessId`, + ], + }), + appNameProbe: () => ({ + file: "powershell", + args: ["-NoProfile", "-Command", "(Get-Process opencodex-desktop -ErrorAction SilentlyContinue).Id"], + }), + childPids: pid => ({ + file: "powershell", + args: ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}").ProcessId`], + }), + uninstall: artifact => [{ file: "msiexec", args: ["/x", artifact, "/qn", "/norestart"] }], + }; +} + +/** Linux: deb and AppImage installs, xdotool gestures, systemd user registration. */ +export function linuxAdapter(runtime: AdapterRuntime): PlatformAdapter { + const run = runtime.run.bind(runtime); + return { + platform: "linux", + dependencies: () => ["dpkg", "dpkg-deb", "dpkg-query", "xdotool", "pgrep", "systemctl", "sudo", "cp", "chmod", "kill", "rm"], + npmLauncher: prefix => `${prefix}/node_modules/.bin/ocx`, + serviceInstall: launcher => ({ file: launcher, args: ["service", "install"] }), + serviceUninstall: launcher => ({ file: launcher, args: ["service", "uninstall"] }), + registrationState: async () => { + if (runtime.fileExists(join(runtime.homeDir(), ".config/systemd/user/opencodex-proxy.service"))) return "present"; + // is-enabled: "enabled"/"linked" exit 0; "disabled" exits 1 but still means the + // unit file EXISTS. is-active: "active" exits 0; "inactive" exits 3 and also + // means the unit is registered. Absence prints "could not be found". + const enabled = await run({ file: "systemctl", args: ["--user", "is-enabled", "opencodex-proxy"] }); + const enabledOut = (enabled.stdout + enabled.stderr).trim(); + if (enabled.ok || /^\w*enabled$|^linked$/.test(enabled.stdout.trim()) || enabled.stdout.trim() === "disabled") return "present"; + if (!/could not be found|no such file|not found/i.test(enabledOut)) return "unknown"; + const active = await run({ file: "systemctl", args: ["--user", "is-active", "opencodex-proxy"] }); + const activeOut = (active.stdout + active.stderr).trim(); + if (active.ok || active.stdout.trim() === "inactive") return "present"; + if (/could not be found|no such file|not found/i.test(activeOut)) return "absent"; + return "unknown"; + }, + registrationFiles: () => [".config/systemd/user/opencodex-proxy.service"], + existingInstallation: (format, packageName) => + format === "deb" && packageName + ? { file: "dpkg-query", args: ["-W", "-f", "${Status}", packageName] } + : null, + async installArtifact(artifact, workDir, format) { + if (format === "deb") { + const packageName = (await run({ file: "dpkg-deb", args: ["-f", artifact, "Package"] })).stdout.trim(); + requireOk("dpkg install", await run({ file: "sudo", args: ["-n", "dpkg", "-i", artifact] })); + try { + const listing = await run({ file: "dpkg", args: ["-L", packageName] }); + const appBinary = listing.stdout.split(/\r?\n/).find(line => line.startsWith("/usr/bin/")) ?? ""; + if (!appBinary) throw new Error(`No /usr/bin executable found in package ${packageName}`); + return { appBinary, packageName, scope: appBinary, evidence: { packageName } }; + } catch (error) { + // The dpkg install already landed; a discovery failure must not strand it. + await run({ file: "sudo", args: ["-n", "dpkg", "-r", packageName] }); + throw error; + } + } + const appsDir = `${workDir}/apps`; + runtime.mkdir(appsDir); + const destination = `${appsDir}/OpenCodex.AppImage`; + requireOk("AppImage copy", await run({ file: "cp", args: [artifact, destination] })); + requireOk("AppImage chmod", await run({ file: "chmod", args: ["+x", destination] })); + return { appBinary: destination, scope: appsDir, evidence: { staged: destination } }; + }, + closeGesture: () => ({ file: "xdotool", args: ["search", "--name", "OpenCodex", "windowclose"] }), + quitGesture: () => ({ + file: "xdotool", + args: ["search", "--name", "OpenCodex", "windowactivate", "--sync", "key", "alt+F4"], + }), + // A stock GNOME session has no tray; on a runner with a tray extension a + // pre-installed hook drives it. The engine records which path was taken. + trayClick: () => null, + trayQuit: () => null, + trayCheck: () => null, + trayInstall: () => null, + windowVisible: () => ({ file: "xdotool", args: ["search", "--name", "OpenCodex"] }), + installedVersion: (format, packageName) => + format === "deb" && packageName + ? { file: "dpkg-query", args: ["-W", "-f", "${Version}", packageName] } + : null, + // Scoped to the pids the elevation monitor sighted — never a blanket pkill. + cancelElevation: pids => + pids.length > 0 ? { file: "kill", args: pids.map(String) } : null, + // pkexec is the first elevation surface; the updater plugin then falls back to a + // zenity or kdialog password dialog, and a cancel must produce NEITHER. + // The updater's full elevation chain is pkexec -> zenity/kdialog -> terminal sudo. + // The gate never invokes sudo during the monitored update windows, so any sighting + // there is attributable to the updater. + elevationProbe: () => ({ file: "pgrep", args: ["-x", "pkexec|zenity|kdialog|sudo"] }), + appProcessProbe: scope => ({ file: "pgrep", args: ["-f", scope] }), + appNameProbe: () => ({ file: "pgrep", args: ["-f", "opencodex-desktop"] }), + childPids: pid => ({ file: "pgrep", args: ["-P", String(pid)] }), + uninstall: (_artifact, workDir, format, packageName) => + format === "deb" && packageName + ? [{ file: "sudo", args: ["-n", "dpkg", "-r", packageName] }] + : [{ file: "rm", args: ["-f", `${workDir}/apps/OpenCodex.AppImage`] }], + }; +} + +function appleScript(...lines: string[]): CommandSpec { + return { file: "osascript", args: ["-e", lines.join(" ; ")] }; +} diff --git a/desktop/scripts/installed-gate.ts b/desktop/scripts/installed-gate.ts new file mode 100644 index 00000000000..4f02f443d42 --- /dev/null +++ b/desktop/scripts/installed-gate.ts @@ -0,0 +1,1002 @@ +/** + * The installed-artifact gate (D9 part two, R3). + * + * Installs the REAL desktop artifact on the host platform, launches it against a staged + * npm runtime, and drives the ownership contract from devlog plan 260921 + * (080_decisions_round2.md): the staged runtime on a non-default port is drained with + * its registration preserved, the desktop install id becomes the owner with exactly one + * consent-generation increment, healthz on the preserved home and port reports the + * bundled sidecar as a child of the app process, close and the OS quit gesture leave + * both pids alive with the window reopenable, a full quit and relaunch restore + * ownership without re-asking consent, and tray Quit lets an in-flight request finish + * before both pids end. On Linux both update paths are exercised (R3): an AppImage + * updates in place without elevation to the exact target bytes, and a deb install asks + * for authorization only after Install is chosen, never retries a cancelled prompt with + * another elevation mechanism, preserves the old version on cancel, and installs the + * new version on accept. + * + * Safety shape, per the external re-audit (110_reaudit.md): + * - preflight-isolation runs BEFORE any mutation. A run that refuses because it found + * an existing app, service registration or default-home state makes ZERO mutating + * calls, cleanup included — cleanup only ever touches resources this run acquired. + * - GUI automation comes from operator-installed hook FILES under --hooks-dir, never + * from dispatch-provided command text. + * - version inputs are strict semver; the npm package name is derived from this + * repository's own package.json, never accepted as an argument. + * + * Every side effect goes through GateDeps so tests can prove call discipline (see the + * refusal test in tests/ci-workflows/installed-gate-drivers.test.ts). + */ + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { commandInvocation } from "../../src/lib/win-exec"; +import { + type CommandSpec, + type GateFormat, + type GatePlatform, + type PlatformAdapter, + type ProcessEvidence, + linuxAdapter, + macosAdapter, + windowsAdapter, +} from "./installed-gate-platforms"; + +export interface GateOptions { + platform: GatePlatform; + format: GateFormat; + artifact: string; + olderArtifact?: string; + workDir: string; + toVersion: string; + fromVersion?: string; + reportPath: string; + hooksDir?: string; + consentHook?: string; + trayClickHook?: string; + trayQuitHook?: string; + trayCheckHook?: string; + trayInstallHook?: string; + /** Hook that answers the deb update's elevation prompt (drives the accept path). */ + elevateAcceptHook?: string; + takeoverTimeoutMs: number; +} + +export interface GatePhaseResult { + phase: string; + status: "pass" | "fail"; + detail: string; + evidence: Record; +} + +export interface GateReport { + platform: GatePlatform; + format: GateFormat; + toVersion: string; + startedAt: string; + finishedAt?: string; + phases: GatePhaseResult[]; + ok?: boolean; +} + +export interface SpawnedProcess { + pid: number; + kill: () => void; + exited: Promise; +} + +/** + * Every side effect the engine can perform. Tests inject fakes; production gets the + * real implementations from defaultGateDeps(). + */ +export interface GateDeps { + run(spec: CommandSpec, env?: Record): Promise; + pidAlive(pid?: number): boolean; + killProcess(pid: number): void; + fileExists(path: string): boolean; + readJsonFile(path: string): unknown; + writeTextFile(path: string, content: string): void; + makeDir(path: string): void; + fetchJson(url: string, init?: { method?: string; headers?: Record; body?: string; timeoutMs?: number }): Promise<{ ok: boolean; status: number; body: unknown }>; + spawnLogged(binary: string, logPath: string, errPath: string, env: Record): SpawnedProcess; + serveMockProvider(): MockProvider; + digestFile(path: string): string | null; + homeDir(): string; + sleep(ms: number): Promise; + readTextFile(path: string): string; +} + +/** Hook names are file names inside --hooks-dir, nothing more. */ +const HOOK_NAME = /^[a-z0-9][a-z0-9._-]*$/i; + +/** + * Version inputs become npm dist-tags and artifact URLs. Strict semver shape is the + * whole grammar they are allowed to carry — anything else (an npm alias like + * npm:other@latest, a flag fragment) is rejected before it can reach npm or a shell. + */ +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +/** + * Numeric-triple semver order. A prerelease suffix sorts before the bare release of the + * same triple; two suffixed versions compare lexically. The gate only needs "strictly + * older" answers for well-formed inputs, which parseGateArguments guarantees. + */ +export function compareSemver(a: string, b: string): number { + const parse = (v: string) => { + const [triple, suffix] = v.split("-", 2); + const parts = triple!.split(".").map(Number); + return { parts, suffix }; + }; + const left = parse(a); + const right = parse(b); + for (let i = 0; i < 3; i++) { + const delta = (left.parts[i] ?? 0) - (right.parts[i] ?? 0); + if (delta !== 0) return delta < 0 ? -1 : 1; + } + if (left.suffix === right.suffix) return 0; + if (left.suffix === undefined) return 1; + if (right.suffix === undefined) return -1; + return left.suffix < right.suffix ? -1 : 1; +} + +export function parseGateArguments(argv: string[]): { options?: GateOptions; error?: string } { + const value = (name: string): string | undefined => { + const index = argv.indexOf(`--${name}`); + return index >= 0 ? argv[index + 1] : undefined; + }; + const required = ["platform", "format", "artifact", "work-dir", "to-version", "report"] as const; + const missing = required.filter(name => !value(name)); + if (missing.length > 0) { + return { error: `missing required arguments: ${missing.map(name => `--${name}`).join(", ")}` }; + } + const platform = value("platform"); + const format = value("format"); + if (platform !== "macos" && platform !== "windows" && platform !== "linux") { + return { error: "--platform must be macos, windows or linux" }; + } + const expectedFormat: Record = { + macos: ["dmg"], + windows: ["msi"], + linux: ["deb", "appimage"], + }; + if (!expectedFormat[platform].includes(format as GateFormat)) { + return { error: `--format ${format} is not a ${platform} artifact format` }; + } + // R3: a Linux gate that cannot see one of the two promised update paths is not a gate, + // so the older artifact and its version are required, exactly like every other input. + if (platform === "linux" && !value("older-artifact")) { + return { error: "--older-artifact is required on linux: both update paths are in scope" }; + } + const toVersion = value("to-version")!; + const fromVersion = value("from-version"); + if (!SEMVER.test(toVersion)) return { error: "--to-version must be a strict semver (x.y.z[-suffix])" }; + if (fromVersion !== undefined && !SEMVER.test(fromVersion)) { + return { error: "--from-version must be a strict semver (x.y.z[-suffix])" }; + } + if (platform === "linux") { + if (!fromVersion) return { error: "--from-version is required on linux: the update phases need a proven-older release" }; + if (fromVersion === toVersion) return { error: "--from-version must differ from --to-version" }; + if (compareSemver(fromVersion, toVersion) >= 0) { + return { error: "--from-version must be strictly older than --to-version" }; + } + } + const hooksDir = value("hooks-dir"); + const hooks: Array<[keyof GateOptions, string | undefined]> = [ + ["consentHook", value("consent-hook")], + ["trayClickHook", value("tray-click-hook")], + ["trayQuitHook", value("tray-quit-hook")], + ["trayCheckHook", value("tray-check-hook")], + ["trayInstallHook", value("tray-install-hook")], + ["elevateAcceptHook", value("elevate-accept-hook")], + ]; + for (const [key, name] of hooks) { + if (name === undefined) continue; + if (!hooksDir) return { error: `--${key.replace(/[A-Z]/g, c => "-" + c.toLowerCase())} requires --hooks-dir` }; + if (!HOOK_NAME.test(name)) { + return { error: `hook name \`${name}\` must be a plain file name inside the hooks directory` }; + } + } + const takeoverTimeoutMs = Number(value("takeover-timeout") ?? 180) * 1000; + if (!Number.isFinite(takeoverTimeoutMs) || takeoverTimeoutMs <= 0) { + return { error: "--takeover-timeout must be a positive number of seconds" }; + } + return { + options: { + platform, + format: format as GateFormat, + artifact: value("artifact")!, + olderArtifact: value("older-artifact"), + workDir: value("work-dir")!, + toVersion, + fromVersion, + reportPath: value("report")!, + hooksDir, + consentHook: value("consent-hook"), + trayClickHook: value("tray-click-hook"), + trayQuitHook: value("tray-quit-hook"), + trayCheckHook: value("tray-check-hook"), + trayInstallHook: value("tray-install-hook"), + elevateAcceptHook: value("elevate-accept-hook"), + takeoverTimeoutMs, + }, + }; +} + +/** + * The staged npm runtime's package spec, derived from this repository's own + * package.json — the gate never takes a package spec as an argument. + */ +export function npmPackageSpec(packageName: string, version: string): string { + return `${packageName}@${version}`; +} + +export function readOwnPackageName(packageJsonText: string): string | undefined { + try { + const parsed = JSON.parse(packageJsonText) as { name?: unknown }; + return typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : undefined; + } catch { + return undefined; + } +} + +export interface OwnershipObservation { + ownerInstallId?: string; + consentGeneration?: number; + raw: unknown; +} + +/** + * Reads the durable ownership fields from service-state.json using lane C's schema: + * the record root carries an \`ownership\` object with the desktop install id and the + * consent generation. Any other shape is no observation, and the phase fails naming + * the file it read — the gate does not guess at schemas. + */ +export function observeOwnership(state: unknown): OwnershipObservation { + if (typeof state !== "object" || state === null) return { raw: state }; + const ownership = (state as Record).ownership; + if (typeof ownership !== "object" || ownership === null) return { raw: state }; + const record = ownership as Record; + return { + ownerInstallId: typeof record.installId === "string" ? record.installId : undefined, + consentGeneration: typeof record.consentGeneration === "number" ? record.consentGeneration : undefined, + raw: state, + }; +} + +export function evaluateOwnership( + before: OwnershipObservation, + after: OwnershipObservation, +): { ok: boolean; detail: string } { + if (before.ownerInstallId !== undefined) { + return { ok: false, detail: "the staged npm runtime already carried an owner; the takeover precondition is an unowned runtime" }; + } + if (!after.ownerInstallId) { + return { ok: false, detail: "the desktop install id is not recorded as owner in service-state.json" }; + } + const beforeGeneration = before.consentGeneration ?? 0; + if (after.consentGeneration === undefined) { + return { ok: false, detail: "no consent generation is recorded in service-state.json" }; + } + if (after.consentGeneration !== beforeGeneration + 1) { + return { + ok: false, + detail: `consent generation moved from ${beforeGeneration} to ${after.consentGeneration}; the contract allows exactly one increment`, + }; + } + return { ok: true, detail: `owner ${after.ownerInstallId} recorded with consent generation ${after.consentGeneration}` }; +} + +/** + * Parses a pid listing. Empty output is no pids — never pid 0, which on POSIX means + * the caller's own process group and must never be signalled from here. + */ +export function parsePidList(stdout: string): number[] { + return stdout + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0) + .map(Number) + .filter(value => Number.isSafeInteger(value) && value > 0); +} + +export function describeGatePhases(options: GateOptions): string[] { + const phases = [ + "preflight-isolation", + "runner-readiness", + "stage-npm-runtime", + "install-artifact", + "launch-and-take-over", + "runtime-identity", + "close-gesture", + "quit-gesture", + "relaunch-consent", + "tray-quit-drains", + ]; + if (options.platform === "linux") phases.push("update-verify"); + phases.push("cleanup"); + return phases; +} + +export function summarizeReport(report: GateReport): string { + const lines = report.phases.map( + phase => `${phase.status === "pass" ? "PASS" : "FAIL"} ${phase.phase}${phase.detail ? ` — ${phase.detail}` : ""}`, + ); + return [`installed-artifact gate: ${report.ok ? "PASS" : "FAIL"} (${report.platform}/${report.format} v${report.toVersion})`, ...lines].join("\n"); +} + +interface Healthz { + status: string; + version?: string; + pid?: number; + role?: string; +} + +const NON_DEFAULT_PORT = 10431; +const ELEVATION_POLL_MS = 250; + +export interface MockProvider { + port: number; + /** Resolves when the first chat completion actually reached the mock. */ + reached: Promise; + /** Lets the held request finish. */ + release: () => void; + stop: () => void; +} + +/** The openai-chat compatible mock the drain phase holds an in-flight request against. */ +export function startMockProvider(): MockProvider { + let finish: () => void = () => {}; + let markReached: () => void = () => {}; + const held = new Promise(resolve => { finish = resolve; }); + const reached = new Promise(resolve => { markReached = resolve; }); + const server = Bun.serve({ + port: 0, + fetch: async request => { + if (new URL(request.url).pathname.endsWith("/chat/completions")) { + markReached(); + await held; + return Response.json({ + id: "gate-drain", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "drained" }, finish_reason: "stop" }], + }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { port: server.port, reached, release: finish, stop: () => server.stop(true) }; +} + +export function defaultGateDeps(): GateDeps { + const spawnProcess = (binary: string, env: Record, outPath?: string, errPath?: string): SpawnedProcess => { + const invocation = commandInvocation(binary, []); + const child = Bun.spawn({ + cmd: [invocation.file, ...invocation.args], + env, + // Bun.spawn accepts a BunFile directly; a FileSink is not a valid stdio target. + stdout: outPath ? Bun.file(outPath) : "ignore", + stderr: errPath ? Bun.file(errPath) : "ignore", + stdin: "ignore", + ...invocation.options, + }); + return { pid: child.pid, kill: () => child.kill(), exited: child.exited }; + }; + return { + run: async (spec, env) => { + const invocation = commandInvocation(spec.file, spec.args); + const proc = Bun.spawn({ + cmd: [invocation.file, ...invocation.args], + stdout: "pipe", + stderr: "pipe", + env: env ?? { ...process.env }, + ...invocation.options, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { ok: exitCode === 0, exitCode, stdout, stderr }; + }, + pidAlive: pid => { + if (typeof pid !== "number") return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + killProcess: pid => process.kill(pid), + fileExists: path => existsSync(path), + readJsonFile: path => { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return undefined; + } + }, + writeTextFile: (path, content) => writeFileSync(path, content), + makeDir: path => mkdirSync(path, { recursive: true }), + fetchJson: async (url, init) => { + const response = await fetch(url, { + method: init?.method, + headers: init?.headers, + body: init?.body, + signal: AbortSignal.timeout(init?.timeoutMs ?? 4000), + }); + let body: unknown = undefined; + try { + body = await response.json(); + } catch { + body = undefined; + } + return { ok: response.ok, status: response.status, body }; + }, + spawnLogged: (binary, logPath, errPath, env) => spawnProcess(binary, env, logPath, errPath), + serveMockProvider: () => startMockProvider(), + digestFile: path => (existsSync(path) ? createHash("sha256").update(readFileSync(path)).digest("hex") : null), + homeDir: () => homedir(), + sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), + readTextFile: path => readFileSync(path, "utf8"), + }; +} + +export async function runGate(options: GateOptions, deps: GateDeps = defaultGateDeps()): Promise { + const report: GateReport = { + platform: options.platform, + format: options.format, + toVersion: options.toVersion, + startedAt: new Date().toISOString(), + phases: [], + }; + const workDir = options.workDir; + const home = join(workDir, "preserved-home"); + const codexHome = join(workDir, "codex-home"); + const npmPrefix = join(workDir, "npm-prefix"); + const port = NON_DEFAULT_PORT; + const isolatedEnv = (): Record => ({ + ...process.env, + OPENCODEX_HOME: home, + CODEX_HOME: codexHome, + }); + // Every command the gate drives runs under the staged homes — a probe or a service + // invocation must never read the runner account's real opencodex or codex home. + const run = (spec: CommandSpec): Promise => deps.run(spec, isolatedEnv()); + const adapter: PlatformAdapter = (options.platform === "macos" ? macosAdapter : options.platform === "windows" ? windowsAdapter : linuxAdapter)( + { run, mkdir: path => deps.makeDir(path), fileExists: path => deps.fileExists(path), homeDir: () => deps.homeDir() }, + ); + const launcher = adapter.npmLauncher(npmPrefix); + const spawned: SpawnedProcess[] = []; + let npmPid: number | undefined; + let appPid: number | undefined; + let appBinary: string | undefined; + let packageName: string | undefined; + let takeoverOwnerId: string | undefined; + let takeoverGeneration: number | undefined; + let takeoverRuntimePid: number | undefined; + let installScope: string | undefined; + let mock: MockProvider | undefined; + let stopVerification = false; + + const record = (phase: string, ok: boolean, detail: string, evidence: Record = {}) => { + report.phases.push({ phase, status: ok ? "pass" : "fail", detail, evidence }); + if (!ok) stopVerification = true; + }; + + const listPids = async (spec: CommandSpec): Promise => { + const result = await run(spec); + if (!result.ok) return []; + return parsePidList(result.stdout); + }; + + const healthz = async (): Promise => { + try { + const response = await deps.fetchJson(`http://127.0.0.1:${port}/healthz`, { timeoutMs: 4000 }); + if (!response.ok) return null; + const body = response.body as Record; + if (body?.status !== "ok") return null; + return { + status: "ok", + version: typeof body.version === "string" ? body.version : undefined, + pid: typeof body.pid === "number" ? body.pid : undefined, + role: typeof body.role === "string" ? body.role : undefined, + }; + } catch { + return null; + } + }; + + const waitFor = async (predicate: () => Promise, timeoutMs: number, everyMs = 500): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return true; + await deps.sleep(everyMs); + } + return await predicate(); + }; + + /** + * A registration is present when any manager probe exits zero OR any on-disk + * registration file exists — unloaded launchd jobs, disabled systemd units and the + * WinSW native service all leave traces a single manager query would miss. + */ + /** Resources THIS invocation placed on the machine; cleanup touches nothing else. */ + const acquired = { service: false, artifact: false }; + + const resolveHook = (name?: string): string | undefined => + name && options.hooksDir ? join(options.hooksDir, name) : undefined; + + /** A pre-installed hook file runs directly, never through a shell. */ + const runHook = async (name: string | undefined, fallback: () => CommandSpec | null): Promise<{ ok: boolean; via: string }> => { + const hook = resolveHook(name); + if (hook) { + if (!deps.fileExists(hook)) return { ok: false, via: `missing hook ${hook}` }; + return { ok: (await run({ file: hook, args: [] })).ok, via: hook }; + } + const spec = fallback(); + if (!spec) return { ok: false, via: "no hook and no platform default" }; + return { ok: (await run(spec)).ok, via: spec.file }; + }; + + const windowVisible = async (): Promise => { + const probe = await run(adapter.windowVisible()); + // macOS prints a window count, Linux prints one window id per line, Windows + // prints nothing and answers through the exit code. + const firstLine = probe.stdout.trim().split(/\r?\n/)[0] ?? ""; + return probe.ok && (firstLine === "" || Number(firstLine) > 0); + }; + + try { + // ---- preflight-isolation: refuse to touch a machine with real opencodex state. + // This phase completes BEFORE anything mutating; a refusal skips every later phase, + // and the cleanup below touches only resources recorded in `acquired`/`spawned`. + // Registration absence must be PROVEN: an unreadable manager probe is "unknown", + // and unknown refuses the run exactly like present does. + const registration = await adapter.registrationState(); + const defaultState = join(deps.homeDir(), ".opencodex", "service-state.json"); + const defaultStatePresent = deps.fileExists(defaultState); + const runningApp = await listPids(adapter.appNameProbe()); + // A dormant install counts too: the gate would overwrite it, and cleanup could + // then remove something this run never installed. For deb the package name is read + // from the artifact (read-only) before probing dpkg. + let dormantInstall = false; + if (options.format === "deb") { + const nameProbe = await run({ file: "dpkg-deb", args: ["-f", options.artifact, "Package"] }); + if (nameProbe.ok && nameProbe.stdout.trim()) { + const installed = adapter.existingInstallation("deb", nameProbe.stdout.trim()); + dormantInstall = installed !== null && (await run(installed)).ok; + } + } else { + const installed = adapter.existingInstallation(options.format); + dormantInstall = installed !== null && (await run(installed)).ok; + } + const isolated = registration === "absent" && !defaultStatePresent && runningApp.length === 0 && !dormantInstall; + record( + "preflight-isolation", + isolated, + isolated + ? "no existing registration, default-home state or running app" + : "this runner already carries opencodex state; the gate would overwrite or remove it — refusing to run", + { registration, defaultStatePresent, runningApp, dormantInstall }, + ); + + if (!stopVerification) { + deps.makeDir(home); + deps.makeDir(codexHome); + // ---- runner-readiness: every external command the adapter needs must resolve + // before an artifact is installed. + const dependencies = [...adapter.dependencies(), "npm"]; + const missing: string[] = []; + for (const dependency of dependencies) { + const probe = process.platform === "win32" + ? await run({ file: "where.exe", args: [dependency] }) + : await run({ file: "sh", args: ["-c", `command -v ${dependency}`] }); + if (!probe.ok) missing.push(dependency); + } + record("runner-readiness", missing.length === 0, + missing.length === 0 ? `${dependencies.length} external commands resolve` : `missing: ${missing.join(", ")}`, + { missing }); + } + + if (!stopVerification) { + // ---- stage-npm-runtime: register FIRST so the recorded pid is the managed one. + // The package name comes from this repository's own package.json; only the + // semver-validated version is operator input. + const staging: Record = {}; + const packageName_ = readOwnPackageName(deps.readTextFile(join(import.meta.dir, "..", "..", "package.json"))); + if (!packageName_) { + record("stage-npm-runtime", false, "could not read this repository's npm package name", staging); + } else { + const spec = npmPackageSpec(packageName_, options.fromVersion ?? options.toVersion); + staging.npmPackage = spec; + const install = await run({ file: "npm", args: ["install", "--prefix", npmPrefix, spec] }); + staging.npmInstallExit = install.exitCode; + mock = deps.serveMockProvider(); + deps.writeTextFile( + join(home, "config.json"), + JSON.stringify( + { + port, + defaultProvider: "gate-mock", + providers: { + "gate-mock": { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${mock.port}/v1`, + apiKey: "gate-mock-key", + }, + }, + }, + null, + 2, + ) + "\n", + ); + let ok = install.ok; + if (ok) { + const registered = await run(adapter.serviceInstall(launcher)); + staging.serviceInstallExit = registered.exitCode; + ok = registered.ok; + // A nonzero exit can still leave a registration behind; if anything is + // registered now, this run owns removing it. + acquired.service = (await adapter.registrationState()) === "present"; + } + if (ok) ok = await waitFor(() => healthz().then(Boolean), 60_000); + const before = await healthz(); + npmPid = before?.pid; + const present = (await adapter.registrationState()) === "present"; + staging.npmRuntime = before; + staging.registrationPresent = present; + ok = ok && present && typeof npmPid === "number"; + record( + "stage-npm-runtime", + ok, + ok ? `managed npm runtime pid ${npmPid} on port ${port}; registration present` : "staging failed; see evidence", + staging, + ); + } + } + + const ownershipBefore = observeOwnership(deps.readJsonFile(join(home, "service-state.json"))); + + if (!stopVerification) { + // ---- install-artifact: the real artifact, installed like a user would. + try { + const installResult = await adapter.installArtifact(options.artifact, workDir, options.format); + appBinary = installResult.appBinary; + packageName = installResult.packageName; + installScope = installResult.scope; + acquired.artifact = true; + record("install-artifact", Boolean(appBinary), `installed ${basename(options.artifact)} -> ${appBinary}`, installResult.evidence); + } catch (error) { + // A partial install is still an acquisition: mark it so cleanup rolls it back. + acquired.artifact = true; + record("install-artifact", false, String(error)); + } + } + + if (!stopVerification && appBinary) { + // ---- launch-and-take-over: consent once, drain the npm runtime, keep the registration. + const launched = deps.spawnLogged(appBinary, join(workDir, "app.log"), join(workDir, "app.err.log"), isolatedEnv()); + spawned.push(launched); + appPid = launched.pid; + if (options.consentHook) await runHook(options.consentHook, () => null); + const taken = await waitFor(async () => { + const now = await healthz(); + return now !== null && typeof now.pid === "number" && now.pid !== npmPid; + }, options.takeoverTimeoutMs); + const after = await healthz(); + const npmDrained = !deps.pidAlive(npmPid); + const registration = (await adapter.registrationState()) === "present"; + const ownershipAfter = observeOwnership(deps.readJsonFile(join(home, "service-state.json"))); + const ownership = evaluateOwnership(ownershipBefore, ownershipAfter); + if (ownership.ok) { + takeoverOwnerId = ownershipAfter.ownerInstallId; + takeoverGeneration = ownershipAfter.consentGeneration; + takeoverRuntimePid = after?.pid; + } + const ok = taken && npmDrained && registration && ownership.ok; + record("launch-and-take-over", ok, [ownership.detail, `npm pid drained: ${npmDrained}`, `registration present: ${registration}`].join("; "), { + before: ownershipBefore.raw, + after: ownershipAfter.raw, + healthzAfter: after, + }); + } + + if (!stopVerification) { + // ---- runtime-identity: healthz on the preserved home+port is the bundled sidecar, + // a child of THIS launched app, reporting THIS release's version. + const now = await healthz(); + const children = appPid !== undefined ? await listPids(adapter.childPids(appPid)) : []; + const portRecord = deps.readJsonFile(join(home, "runtime-port.json")) as { port?: number; pid?: number } | undefined; + const ok = + now?.pid !== undefined && + children.includes(now.pid) && + portRecord?.port === port && + portRecord?.pid === now.pid && + now.version === options.toVersion; + record("runtime-identity", ok, ok + ? `healthz pid ${now?.pid} v${now?.version} is a child of app pid ${appPid} on preserved port ${port}` + : "the answering runtime is not the bundled sidecar of the launched app on the preserved home", + { healthz: now, appPid, sidecarCandidates: children, runtimePortRecord: portRecord }); + } + + const gesture = async (phase: string, spec: CommandSpec) => { + if (stopVerification) return; + const before = await healthz(); + const gestureResult = await run(spec); + // The gesture must actually hide the window; a no-op command exit is not the + // contract. + const windowHidden = await waitFor(async () => !(await windowVisible()), 10_000); + const after = await healthz(); + const runtimeAlive = before?.pid !== undefined && before.pid === after?.pid; + const appAlive = deps.pidAlive(appPid); + const reopen = await runHook(options.trayClickHook, () => adapter.trayClick()); + const visible = await waitFor(windowVisible, 15_000); + const ok = gestureResult.ok && windowHidden && runtimeAlive && appAlive && reopen.ok && visible; + record(phase, ok, + `gesture exit ${gestureResult.exitCode}; window hidden: ${windowHidden}; runtime pid ${after?.pid} alive: ${runtimeAlive}; app pid ${appPid} alive: ${appAlive}; window reopened via ${reopen.via}: ${visible}`, + { before, after }); + }; + + await gesture("close-gesture", adapter.closeGesture()); + await gesture("quit-gesture", adapter.quitGesture()); + + if (!stopVerification && appBinary) { + // ---- relaunch-consent: a FULL quit (tray Quit drains and ends both pids), then a + // cold relaunch must restore ownership WITHOUT asking again — the same install id, + // the same consent generation. Watching a single-instance duplicate exit is not + // this contract. + const quit = await runHook(options.trayQuitHook, () => adapter.trayQuit()); + // Both pids — the app AND the runtime it owned at takeover — must actually end + // before the relaunch means anything. + const previousAppPid = appPid; + const ended = await waitFor(async () => + !deps.pidAlive(previousAppPid) + && !deps.pidAlive(takeoverRuntimePid) + && (installScope === undefined || (await listPids(adapter.appProcessProbe(installScope))).length === 0), + 30_000); + let ownershipRestored = false; + let relaunchHealth: Healthz | null = null; + if (ended) { + const relaunched = deps.spawnLogged(appBinary, join(workDir, "relaunch.log"), join(workDir, "relaunch.err.log"), isolatedEnv()); + spawned.push(relaunched); + appPid = relaunched.pid; + const up = await waitFor(() => healthz().then(Boolean), 60_000); + relaunchHealth = await healthz(); + const ownership = observeOwnership(deps.readJsonFile(join(home, "service-state.json"))); + ownershipRestored = up + && ownership.ownerInstallId !== undefined + && ownership.ownerInstallId === takeoverOwnerId; + // A re-asked consent would move the generation; identical generation is the + // proof that nothing was asked. + ownershipRestored &&= ownership.consentGeneration !== undefined && ownership.consentGeneration === takeoverGeneration; + } + const ok = quit.ok && ended && ownershipRestored; + record("relaunch-consent", ok, ok + ? `full quit and cold relaunch restored owner ${takeoverOwnerId} without re-asking consent` + : "ownership was not restored after a cold relaunch, or consent was asked again", + { quitVia: quit.via, ended, ownerAfterRelaunch: relaunchHealth, takeoverOwnerId }); + } + + if (!stopVerification) { + // ---- tray-quit-drains: the request must be verifiably in flight when Quit fires. + const before = await healthz(); + const request = deps.fetchJson(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer gate-mock-key" }, + body: JSON.stringify({ model: "gate-mock/gate-model", messages: [{ role: "user", content: "hold" }] }), + timeoutMs: 90_000, + }).then(response => response.status); + const inFlight = await Promise.race([ + mock!.reached.then(() => true), + deps.sleep(15_000).then(() => false), + ]); + const quit = await runHook(options.trayQuitHook, () => adapter.trayQuit()); + await deps.sleep(2000); + mock?.release(); + let requestStatus: number | null = null; + try { + requestStatus = await request; + } catch { + requestStatus = null; + } + const drained = requestStatus === 200; + const bothEnded = await waitFor(async () => !deps.pidAlive(before?.pid) && (installScope === undefined || (await listPids(adapter.appProcessProbe(installScope))).length === 0), 30_000); + const ok = inFlight && quit.ok && drained && bothEnded; + record("tray-quit-drains", ok, + `request in flight at Quit: ${inFlight}; tray Quit driven via ${quit.via}; request finished with ${requestStatus}; both pids ended: ${bothEnded}`, + { runtimePid: before?.pid, requestStatus }); + } + + if (!stopVerification && options.platform === "linux" && options.olderArtifact && options.fromVersion) { + // ---- update-verify (R3): both Linux formats update through their own path, on + // both authorization outcomes. A failed prerequisite stops the phase BEFORE the + // next mutation, never after it. + for (const spec of adapter.uninstall(options.artifact, workDir, options.format, packageName)) { + await run(spec); + } + const older = await adapter.installArtifact(options.olderArtifact, workDir, options.format); + packageName = older.packageName; + installScope = older.scope; + const oldApp = deps.spawnLogged(older.appBinary, join(workDir, "older-app.log"), join(workDir, "older-app.err.log"), isolatedEnv()); + spawned.push(oldApp); + appPid = oldApp.pid; + const oldHealthy = await waitFor(() => healthz().then(Boolean), 60_000); + const preVersion = adapter.installedVersion(options.format, packageName); + const preVersionOutput = preVersion ? await run(preVersion) : null; + const preVersionText = preVersionOutput?.ok ? preVersionOutput.stdout.trim() : ""; + const preDigest = options.format === "appimage" ? deps.digestFile(older.appBinary) : null; + const targetDigest = options.format === "appimage" ? deps.digestFile(options.artifact) : null; + + // One continuous monitor across the whole update operation: a fixed window can + // close before download and signature verification reach the elevation step. + // Sightings are attributed by the timestamp of each driver action. + const sightings: Array<{ pid: number; at: number }> = []; + const elevationProbe = adapter.elevationProbe(); + let monitoring = true; + const monitorTask = (async () => { + while (monitoring && elevationProbe) { + for (const pid of await listPids(elevationProbe)) sightings.push({ pid, at: Date.now() }); + await deps.sleep(ELEVATION_POLL_MS); + } + })(); + const stopMonitor = async () => { monitoring = false; await monitorTask; }; + const sightingsAfter = (timestamp: number): number[] => + [...new Set(sightings.filter(sighting => sighting.at >= timestamp).map(sighting => sighting.pid))]; + + try { + let check = { ok: false, via: "skipped: old app never became healthy" }; + let install = { ok: false, via: "skipped: old app never became healthy" }; + let installStart = Number.POSITIVE_INFINITY; + if (oldHealthy) { + check = await runHook(options.trayCheckHook, () => adapter.trayCheck()); + installStart = Date.now(); + install = await runHook(options.trayInstallHook, () => adapter.trayInstall()); + } + const elevationDuringCheck = [...new Set( + sightings.filter(sighting => sighting.at < installStart).map(sighting => sighting.pid), + )]; + + if (options.format === "appimage") { + // The installed file must become byte-identical to the target artifact — a + // changed digest alone would pass for an update to the wrong version, and a + // missing pre/target digest would make the transition vacuous. + await waitFor(async () => deps.digestFile(older.appBinary) === targetDigest, 120_000); + const postDigest = deps.digestFile(older.appBinary); + const anyElevation = sightingsAfter(0); + const ok = oldHealthy && check.ok && install.ok + && elevationDuringCheck.length === 0 && anyElevation.length === 0 + && preDigest !== null && targetDigest !== null && preDigest !== targetDigest + && postDigest !== null && postDigest === targetDigest; + record("update-verify", ok, + `AppImage updated in place to the exact target artifact (digest match: ${postDigest === targetDigest}); no elevation anywhere (${anyElevation.length} sighted); path kept`, + { preDigest, postDigest, targetDigest, elevation: anyElevation }); + } else { + // Cancel path: wait for the prompt, dismiss exactly the sighted pids, prove + // they exited, then prove NO elevation mechanism retries (the pinned plugin + // otherwise falls back pkexec -> zenity/kdialog -> sudo), and the version + // never moved. + const prompted = await waitFor(async () => sightingsAfter(installStart).length > 0, 90_000); + const elevation = sightingsAfter(installStart); + const cancel = adapter.cancelElevation(elevation); + let cancelOk = elevation.length === 0; + if (cancel && elevation.length > 0) { + cancelOk = (await run(cancel)).ok; + cancelOk &&= await waitFor(async () => elevation.every(pid => !deps.pidAlive(pid)), 10_000); + } + const cancelDoneAt = Date.now(); + await deps.sleep(10_000); + const retriedElevation = sightingsAfter(cancelDoneAt); + const settledVersion = adapter.installedVersion(options.format, packageName); + const settledVersionOutput = settledVersion ? await run(settledVersion) : null; + const settledVersionText = settledVersionOutput?.ok ? settledVersionOutput.stdout.trim() : ""; + const cancelPreserved = preVersionText !== "" && preVersionText === options.fromVersion && settledVersionText === preVersionText; + const cancelOkAll = oldHealthy && check.ok && install.ok + && elevationDuringCheck.length === 0 && prompted + && cancelOk && retriedElevation.length === 0 && cancelPreserved; + + // Accept path: only after the cancel path held. Drive Install again, answer + // through the operator hook, and require the package to reach the target. + let acceptOk = false; + let acceptEvidence: Record = { skipped: "no --elevate-accept-hook" }; + if (options.elevateAcceptHook && cancelOkAll) { + const acceptStart = Date.now(); + const installAgain = await runHook(options.trayInstallHook, () => adapter.trayInstall()); + const promptedAgain = await waitFor(async () => sightingsAfter(acceptStart).length > 0, 90_000); + let hookOk = false; + if (promptedAgain) { + hookOk = (await runHook(options.elevateAcceptHook, () => null)).ok; + } + const accepted = await waitFor(async () => { + const probe = adapter.installedVersion(options.format, packageName); + if (!probe) return false; + const result = await run(probe); + return result.ok && result.stdout.trim() === options.toVersion; + }, 120_000); + acceptOk = installAgain.ok && promptedAgain && hookOk && accepted; + acceptEvidence = { installAgain: installAgain.ok, promptedAgain, hookOk, accepted }; + } else if (options.elevateAcceptHook) { + acceptEvidence = { skipped: "cancel path failed; accept not attempted" }; + } + const ok = cancelOkAll && acceptOk; + record("update-verify", ok, + `deb cancel path preserved ${settledVersionText} with no elevation retry (${retriedElevation.length}); accept path reached ${options.toVersion}: ${acceptOk}`, + { preVersion: preVersionText, postCancelVersion: settledVersionText, cancelOk, prompted, retriedElevation, accept: acceptEvidence }); + } + } finally { + await stopMonitor(); + } + } + } catch (error) { + // A thrown exception is a fatal phase of its own: without this, a crash between + // phases could leave a report whose recorded phases all pass. + record("fatal-error", false, String(error)); + } finally { + // ---- cleanup: rolls back ONLY what this invocation acquired. A preflight refusal + // means nothing here runs against machine state: the gate must never destroy an + // existing installation it detected. Every rollback step runs; a failure fails the + // phase but never stops the remaining steps. + const cleanupEvidence: Record = {}; + let cleanupOk = true; + const fail = (key: string, error: unknown) => { + cleanupOk = false; + cleanupEvidence[key] = String(error); + }; + for (const child of spawned) { + try { child.kill(); } catch (error) { fail(`spawned-${child.pid}`, error); } + } + // Processes the gate no longer owns: an AppImage update restarts detached from the + // original spawn handle. Only swept when this run launched an app at all. + if (appPid !== undefined && installScope !== undefined) { + for (const pid of await listPids(adapter.appProcessProbe(installScope))) { + try { deps.killProcess(pid); } catch (error) { fail(`app-${pid}`, error); } + } + } + if (deps.pidAlive(npmPid)) { + try { deps.killProcess(npmPid!); } catch (error) { fail("npm-runtime", error); } + } + if (acquired.service) { + try { + const result = await run(adapter.serviceUninstall(launcher)); + if (!result.ok) fail("service-uninstall", result.stderr.trim() || `exit ${result.exitCode}`); + } catch (error) { fail("service-uninstall", error); } + } + if (acquired.artifact) { + for (const spec of adapter.uninstall(options.artifact, workDir, options.format, packageName)) { + try { + const result = await run(spec); + if (!result.ok) fail(`uninstall:${spec.args[1] ?? spec.file}`, result.stderr.trim() || `exit ${result.exitCode}`); + } catch (error) { fail("uninstall", error); } + } + } + mock?.stop(); + record("cleanup", cleanupOk, cleanupOk ? "everything the gate installed was rolled back" : "a rollback step failed; see evidence", cleanupEvidence); + report.finishedAt = new Date().toISOString(); + // Green means every phase ran AND passed — a report missing phases (a crash, an + // early refusal) is not green even if everything recorded passed. + const expectedPhases = describeGatePhases(options); + const covered = expectedPhases.every(name => report.phases.some(phase => phase.phase === name)); + report.ok = report.phases.every(phase => phase.status === "pass") && covered; + deps.writeTextFile(options.reportPath, `${JSON.stringify(report, null, 2)}\n`); + } + return report; +} + +if (import.meta.main) { + const parsed = parseGateArguments(Bun.argv.slice(2)); + if (!parsed.options || parsed.error) { + console.error(parsed.error ?? "invalid arguments"); + console.error( + "usage: installed-gate.ts --platform --format --artifact " + + " --older-artifact --work-dir

--to-version --from-version --report " + + " [--hooks-dir ] [--consent-hook ] [--tray-click-hook ] [--tray-quit-hook ]" + + " [--tray-check-hook ] [--tray-install-hook ] [--elevate-accept-hook ] [--takeover-timeout ]", + ); + process.exit(2); + } + const report = await runGate(parsed.options); + console.log(summarizeReport(report)); + process.exit(report.ok ? 0 : 1); +} diff --git a/desktop/scripts/updater-manifest.ts b/desktop/scripts/updater-manifest.ts index e67459d8481..b21392fa113 100644 --- a/desktop/scripts/updater-manifest.ts +++ b/desktop/scripts/updater-manifest.ts @@ -31,7 +31,13 @@ const platformFiles: Record = { "darwin-aarch64": "macos.app.tar.gz", "darwin-x86_64": "macos.app.tar.gz", "windows-x86_64": "windows-x64.msi", + // The AppImage is the plugin's default Linux target: it keeps the plain os-arch key so + // AppImage installs from releases before the deb target existed keep resolving updates. "linux-x86_64": "linux-x86_64.AppImage", + // A deb install cannot apply an AppImage payload (the updater validates the downloaded + // bytes as a real .deb before installing), so it must resolve a distinct key. The shell + // selects this key from the bundle type embedded at packaging time; see updater.rs. + "linux-x86_64-deb": "linux-amd64.deb", }; export function buildUpdaterManifest(options: UpdaterManifestOptions): UpdaterManifest { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 95bc1a674a6..f5bb3fca134 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2554,6 +2554,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tauri-utils", "tokio", "uuid", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 2548b000cd0..1ec7348b749 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ serde = { version = "=1.0.219", features = ["derive"] } serde_json = "=1.0.140" uuid = { version = "=1.18.1", features = ["v4"] } tauri = { version = "=2.11.6", features = ["tray-icon", "image-png"] } +tauri-utils = "=2.9.3" tauri-plugin-autostart = "=2.5.0" tauri-plugin-opener = "=2.5.3" tauri-plugin-process = "=2.3.0" diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs index 4638c1565d8..57086bd9193 100644 --- a/desktop/src-tauri/src/updater.rs +++ b/desktop/src-tauri/src/updater.rs @@ -5,8 +5,42 @@ use tauri_plugin_updater::{Update, UpdaterExt}; pub struct PendingUpdate(pub Mutex>); +/// The manifest key a Linux install must resolve, or None to keep the updater's default +/// os-arch key (linux-x86_64, windows-x86_64, darwin-*). +/// +/// A deb install cannot apply the AppImage payload: the updater validates the downloaded +/// bytes as a real .deb before installing through package-manager elevation, so it must +/// resolve the deb's own manifest key. The bundle type is patched into the binary at +/// packaging time, so the answer is embedded per artifact, not detected at runtime. The +/// AppImage keeps the default key, which is also what installs from releases before the +/// deb target existed already resolve. +#[cfg(any(target_os = "linux", test))] +pub fn linux_updater_target( + bundle: Option, +) -> Option<&'static str> { + match bundle { + Some(tauri_utils::config::BundleType::Deb) => Some("linux-x86_64-deb"), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn configured_updater_target() -> Option<&'static str> { + linux_updater_target(tauri_utils::platform::bundle_type()) +} + +#[cfg(not(target_os = "linux"))] +fn configured_updater_target() -> Option<&'static str> { + None +} + pub async fn check(app: &AppHandle) -> Result, String> { - app.updater() + let mut builder = app.updater_builder(); + if let Some(target) = configured_updater_target() { + builder = builder.target(target); + } + builder + .build() .map_err(|error| error.to_string())? .check() .await @@ -62,10 +96,30 @@ pub async fn check_and_show(app: &AppHandle) { #[cfg(test)] mod tests { - use super::update_label; + use super::{linux_updater_target, update_label}; + use tauri_utils::config::BundleType; #[test] fn formats_update_menu_label() { assert_eq!(update_label("2.62.0"), "Install update v2.62.0"); } + + #[test] + fn deb_installs_resolve_their_own_updater_key() { + assert_eq!( + linux_updater_target(Some(BundleType::Deb)), + Some("linux-x86_64-deb") + ); + } + + #[test] + fn appimage_installs_keep_the_default_updater_key() { + assert_eq!(linux_updater_target(Some(BundleType::AppImage)), None); + } + + #[test] + fn unbundled_builds_keep_the_default_updater_key() { + // Dev builds and any format without a patcher entry resolve the default key. + assert_eq!(linux_updater_target(None), None); + } } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 881526d6ff8..669f74ef70e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,6 +168,7 @@ }, "explicit": { "release-desktop-scripts.test.ts": "ci-workflows", + "installed-gate-drivers.test.ts": "ci-workflows", "gui-desktop-sidecar-script.test.ts": "gui", "standalone-build-script.test.ts": "gui", "standalone-service.test.ts": "service", diff --git a/tests/ci-workflows/installed-gate-drivers.test.ts b/tests/ci-workflows/installed-gate-drivers.test.ts new file mode 100644 index 00000000000..21c7039caf9 --- /dev/null +++ b/tests/ci-workflows/installed-gate-drivers.test.ts @@ -0,0 +1,552 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + compareSemver, + describeGatePhases, + evaluateOwnership, + npmPackageSpec, + observeOwnership, + parseGateArguments, + parsePidList, + readOwnPackageName, + runGate, + summarizeReport, + type GateDeps, + type GateOptions, + type GateReport, +} from "../../desktop/scripts/installed-gate"; +import { + linuxAdapter, + macosAdapter, + windowsAdapter, + type ProcessEvidence, +} from "../../desktop/scripts/installed-gate-platforms"; +import { repoPath } from "../helpers/repo-root"; + +const okRunner = { + run: async (): Promise => ({ ok: true, exitCode: 0, stdout: "", stderr: "" }), + mkdir: () => {}, + fileExists: () => false, + homeDir: () => "/fake-home", +}; + +function baseArgv(): string[] { + return [ + "--platform", "macos", + "--format", "dmg", + "--artifact", "gate/OpenCodex-2.62.0-macos.dmg", + "--work-dir", "gate/work", + "--to-version", "2.62.0", + "--report", "gate/report.json", + ]; +} + +describe("installed-gate argument parsing", () => { + test("accepts a complete macOS invocation", () => { + const parsed = parseGateArguments(baseArgv()); + expect(parsed.error).toBeUndefined(); + expect(parsed.options?.platform).toBe("macos"); + expect(parsed.options?.takeoverTimeoutMs).toBe(180_000); + }); + + test("lists every missing required argument", () => { + const parsed = parseGateArguments([]); + expect(parsed.error).toContain("--platform"); + expect(parsed.error).toContain("--report"); + }); + + test("rejects a format that does not belong to the platform", () => { + const parsed = parseGateArguments(baseArgv().map(a => a === "dmg" ? "deb" : a)); + expect(parsed.error).toContain("not a macos artifact format"); + }); + + test("rejects an unknown platform", () => { + const parsed = parseGateArguments(baseArgv().map(a => a === "macos" ? "freebsd" : a)); + expect(parsed.error).toContain("--platform must be"); + }); + + test("R3: linux runs without an older artifact are rejected, update verification is not optional", () => { + const linux = baseArgv().map(a => (a === "macos" ? "linux" : a === "dmg" ? "deb" : a)); + expect(parseGateArguments(linux).error).toContain("--older-artifact"); + const linuxWithArtifact = [...linux, "--older-artifact", "gate/older.deb"]; + expect(parseGateArguments(linuxWithArtifact).error).toContain("--from-version"); + expect(parseGateArguments([...linuxWithArtifact, "--from-version", "2.61.0"]).options).toBeDefined(); + }); + + test("the older artifact must actually be older", () => { + const linux = [ + ...baseArgv().map(a => (a === "macos" ? "linux" : a === "dmg" ? "deb" : a)), + "--older-artifact", "gate/older.deb", + ]; + expect(parseGateArguments([...linux, "--from-version", "2.62.0"]).error).toContain("differ"); + expect(parseGateArguments([...linux, "--from-version", "2.63.0"]).error).toContain("strictly older"); + expect(parseGateArguments([...linux, "--from-version", "2.62.0-rc.1"]).options).toBeDefined(); + }); + + test("compareSemver orders numeric triples and prerelease suffixes", () => { + expect(compareSemver("2.61.0", "2.62.0")).toBeLessThan(0); + expect(compareSemver("2.62.0", "2.61.0")).toBeGreaterThan(0); + expect(compareSemver("2.62.0-rc.1", "2.62.0")).toBeLessThan(0); + expect(compareSemver("2.62.0", "2.62.0")).toBe(0); + }); + + test("rejects a non-positive takeover timeout", () => { + expect(parseGateArguments([...baseArgv(), "--takeover-timeout", "0"]).error).toContain("--takeover-timeout"); + expect(parseGateArguments([...baseArgv(), "--takeover-timeout", "45"]).options?.takeoverTimeoutMs).toBe(45_000); + }); + + test("hooks are file names inside --hooks-dir, never command text or paths", () => { + expect(parseGateArguments([...baseArgv(), "--consent-hook", "answer-consent"]).error) + .toContain("--hooks-dir"); + const withDir = [...baseArgv(), "--hooks-dir", "gate/hooks"]; + expect(parseGateArguments([...withDir, "--consent-hook", "answer-consent"]).options?.consentHook).toBe("answer-consent"); + expect(parseGateArguments([...withDir, "--consent-hook", "../escape"]).error).toContain("plain file name"); + expect(parseGateArguments([...withDir, "--tray-quit-hook", "a/b"]).error).toContain("plain file name"); + expect(parseGateArguments([...withDir, "--tray-quit-hook", "rm -rf /"]).error).toContain("plain file name"); + }); + + test("the npm package spec is derived from the repository, never taken as an argument", () => { + expect(npmPackageSpec("@example/opencodex", "2.61.0")).toBe("@example/opencodex@2.61.0"); + expect(readOwnPackageName('{"name":"@bitkyc08/opencodex"}')).toBe("@bitkyc08/opencodex"); + expect(readOwnPackageName("not json")).toBeUndefined(); + }); + + test("version inputs are strict semver, never npm package grammar", () => { + // `opencodex@${input}` with an alias payload would install an arbitrary package. + expect(parseGateArguments(baseArgv().map(a => a === "2.62.0" ? "npm:evil@latest" : a)).error).toContain("strict semver"); + const linux = baseArgv().map(a => (a === "macos" ? "linux" : a === "dmg" ? "deb" : a)); + const linuxBase = [...linux, "--older-artifact", "gate/older.deb"]; + expect(parseGateArguments(linuxBase).error).toContain("--from-version"); + expect(parseGateArguments([...linuxBase, "--from-version", "2.62.0"]).error).toContain("differ"); + expect(parseGateArguments([...linuxBase, "--from-version", "2.61.0"]).options?.fromVersion).toBe("2.61.0"); + }); +}); + +describe("installed-gate pid parsing", () => { + test("empty probe output is no pids, never pid 0", () => { + expect(parsePidList("")).toEqual([]); + expect(parsePidList("\n\n")).toEqual([]); + }); + + test("only positive safe integers survive", () => { + expect(parsePidList("123\n456\n")).toEqual([123, 456]); + expect(parsePidList(" 42 \n0\n-7\nabc\n")).toEqual([42]); + }); +}); + +describe("installed-gate ownership contract", () => { + test("reads the lane C schema: record root carries an ownership object", () => { + const observed = observeOwnership({ ownership: { installId: "install-1", consentGeneration: 3 } }); + expect(observed.ownerInstallId).toBe("install-1"); + expect(observed.consentGeneration).toBe(3); + }); + + test("does not guess at other shapes — an unknown schema is no observation", () => { + expect(observeOwnership({ owner: { installId: "x" } }).ownerInstallId).toBeUndefined(); + expect(observeOwnership({ installId: "x", consentGeneration: 1 }).consentGeneration).toBeUndefined(); + expect(observeOwnership("not-an-object").ownerInstallId).toBeUndefined(); + }); + + test("accepts exactly one consent-generation increment with a recorded owner", () => { + // The takeover precondition is an UNOWNED staged runtime: a pre-existing owner + // means the observation is not the takeover this gate exists to prove. + const before = observeOwnership({}); + const after = observeOwnership({ ownership: { installId: "install-1", consentGeneration: 1 } }); + expect(evaluateOwnership(before, after).ok).toBe(true); + }); + + test("rejects a takeover from an already-owned runtime", () => { + const before = observeOwnership({ ownership: { installId: "other", consentGeneration: 0 } }); + const after = observeOwnership({ ownership: { installId: "install-1", consentGeneration: 1 } }); + const verdict = evaluateOwnership(before, after); + expect(verdict.ok).toBe(false); + expect(verdict.detail).toContain("unowned"); + }); + + test("rejects a takeover that recorded no owner", () => { + const before = observeOwnership({}); + const after = observeOwnership({ ownership: { consentGeneration: 1 } }); + const verdict = evaluateOwnership(before, after); + expect(verdict.ok).toBe(false); + expect(verdict.detail).toContain("install id"); + }); + + test("rejects more than one consent-generation increment", () => { + const before = observeOwnership({ ownership: { consentGeneration: 0 } }); + const after = observeOwnership({ ownership: { installId: "i", consentGeneration: 2 } }); + expect(evaluateOwnership(before, after).ok).toBe(false); + }); + + test("treats a missing generation after consent as a failure, never as zero", () => { + const before = observeOwnership({ ownership: { consentGeneration: 0 } }); + const after = observeOwnership({ ownership: { installId: "i" } }); + expect(evaluateOwnership(before, after).ok).toBe(false); + }); +}); + +describe("installed-gate phase plan and report", () => { + const optionsFor = (platform: GateOptions["platform"]): GateOptions => ({ + platform, + format: platform === "linux" ? "deb" : platform === "windows" ? "msi" : "dmg", + artifact: "a", + olderArtifact: "b", + workDir: "w", + toVersion: "2.62.0", + fromVersion: "2.61.0", + reportPath: "r.json", + takeoverTimeoutMs: 1000, + }); + + test("linux gates carry the update phase before cleanup", () => { + const phases = describeGatePhases(optionsFor("linux")); + expect(phases).toContain("update-verify"); + expect(phases.indexOf("update-verify")).toBe(phases.length - 2); + expect(phases.at(-1)).toBe("cleanup"); + }); + + test("macOS and Windows gates stop after the drain contract", () => { + expect(describeGatePhases(optionsFor("macos"))).not.toContain("update-verify"); + expect(describeGatePhases(optionsFor("windows"))).not.toContain("update-verify"); + }); + + test("every gate opens with the isolation preflight", () => { + for (const platform of ["macos", "windows", "linux"] as const) { + expect(describeGatePhases(optionsFor(platform))[0]).toBe("preflight-isolation"); + expect(describeGatePhases(optionsFor(platform))[1]).toBe("runner-readiness"); + } + }); + + test("a full quit and cold relaunch replaces the single-instance observation", () => { + for (const platform of ["macos", "windows", "linux"] as const) { + const phases = describeGatePhases(optionsFor(platform)); + expect(phases).toContain("relaunch-consent"); + expect(phases).not.toContain("second-launch-consent"); + } + }); + + test("the report is red when any phase fails, and summarizes in report order", () => { + const report: GateReport = { + platform: "macos", + format: "dmg", + toVersion: "2.62.0", + startedAt: "2026-09-21T00:00:00.000Z", + phases: [ + { phase: "stage-npm-runtime", status: "pass", detail: "ok", evidence: {} }, + { phase: "install-artifact", status: "fail", detail: "broken", evidence: {} }, + ], + }; + const summary = summarizeReport(report); + expect(summary).toContain("FAIL install-artifact — broken"); + expect(summary.startsWith("installed-artifact gate:")).toBe(true); + }); +}); + +describe("the refusal path is mutation-free (P0)", () => { + const optionsFor = (platform: GateOptions["platform"]): GateOptions => ({ + platform, + format: platform === "linux" ? "deb" : platform === "windows" ? "msi" : "dmg", + artifact: "a", + olderArtifact: "b", + workDir: "w", + toVersion: "2.62.0", + fromVersion: "2.61.0", + reportPath: "r.json", + takeoverTimeoutMs: 1000, + }); + + test("a run refused for existing state makes zero mutating calls, cleanup included", async () => { + const calls: string[] = []; + const killed: number[] = []; + const written: string[] = []; + const directories: string[] = []; + const deps: GateDeps = { + // Every command answers "absent" except the default-home state file, which + // fileExists reports as present — the refusal trigger. + run: async spec => { + calls.push([spec.file, ...spec.args].join(" ")); + return { ok: false, exitCode: 1, stdout: "", stderr: "" }; + }, + pidAlive: () => false, + killProcess: pid => killed.push(pid), + fileExists: path => path.endsWith(".opencodex/service-state.json"), + readJsonFile: () => undefined, + writeTextFile: path => { written.push(path); }, + makeDir: path => { directories.push(path); }, + fetchJson: async () => ({ ok: false, status: 0, body: undefined }), + spawnLogged: () => { throw new Error("refusal must never spawn"); }, + serveMockProvider: () => { throw new Error("refusal must never serve the mock"); }, + digestFile: () => null, + homeDir: () => "/fake-home", + sleep: () => Promise.resolve(), + readTextFile: () => "{}", + }; + + const report = await runGate(optionsFor("macos"), deps); + + expect(report.ok).toBe(false); + expect(report.phases[0]?.phase).toBe("preflight-isolation"); + expect(report.phases[0]?.status).toBe("fail"); + // Only read-only probes may have run: the registration probes, the default-state + // file check and the app process probe. Nothing that installs, uninstalls, kills, + // writes outside the report, or registers a service. + const allowedProbeBinaries = new Set(["launchctl", "pgrep"]); + for (const call of calls) { + const binary = call.split(" ")[0]!; + expect(allowedProbeBinaries.has(binary), `mutating call reached on refusal: ${call}`).toBe(true); + } + expect(killed).toEqual([]); + expect(written).toEqual(["r.json"]); + // Even the gate's own scratch dirs are created only after the preflight passes. + expect(directories).toEqual([]); + // And the refusal was the reason: no other phase ran. + expect(report.phases.map(phase => phase.phase)).toEqual(["preflight-isolation", "cleanup"]); + }); +}); + +describe("installed-gate platform adapters", () => { + test("every adapter declares its external commands", () => { + for (const adapter of [macosAdapter(okRunner), windowsAdapter(okRunner), linuxAdapter(okRunner)]) { + expect(adapter.dependencies().length).toBeGreaterThan(0); + for (const dependency of adapter.dependencies()) { + // Command names resolve through PATH; a few system tools are invoked by their + // absolute path (PlistBuddy) and are declared that way. + expect(dependency).toMatch(/^([a-z0-9.-]+|\/[a-zA-Z0-9._/-]+)$/); + } + } + }); + + test("dependency lists enumerate exactly what the adapters shell out to", () => { + expect(new Set(macosAdapter(okRunner).dependencies())).toEqual( + new Set(["hdiutil", "osascript", "pgrep", "launchctl", "cp", "rm", "/usr/libexec/PlistBuddy"]), + ); + expect(new Set(windowsAdapter(okRunner).dependencies())).toEqual( + new Set(["msiexec", "powershell", "schtasks", "sc"]), + ); + expect(new Set(linuxAdapter(okRunner).dependencies())).toEqual( + new Set(["dpkg", "dpkg-deb", "dpkg-query", "xdotool", "pgrep", "systemctl", "sudo", "cp", "chmod", "kill", "rm"]), + ); + }); + + test("registration evidence covers the manager AND the on-disk artifact", () => { + expect(macosAdapter(okRunner).registrationFiles()).toEqual(["Library/LaunchAgents/com.opencodex.proxy.plist"]); + expect(linuxAdapter(okRunner).registrationFiles()).toEqual([".config/systemd/user/opencodex-proxy.service"]); + }); + + test("the npm launcher is resolved inside the staged prefix, never PATH", () => { + expect(macosAdapter(okRunner).npmLauncher("/prefix")).toBe("/prefix/node_modules/.bin/ocx"); + expect(linuxAdapter(okRunner).npmLauncher("/prefix")).toBe("/prefix/node_modules/.bin/ocx"); + expect(windowsAdapter(okRunner).npmLauncher("C:\\prefix")).toBe("C:\\prefix\\node_modules\\.bin\\ocx.cmd"); + }); + + test("registration state is tri-state: present, absent, and unknown refuse differently", async () => { + const scriptRunner = (responses: Array<{ ok: boolean; stdout?: string; stderr?: string }>) => { + const queue = [...responses]; + return { + run: async (): Promise => { + const next = queue.shift() ?? { ok: false }; + return { ok: next.ok, exitCode: next.ok ? 0 : 1, stdout: next.stdout ?? "", stderr: next.stderr ?? "" }; + }, + mkdir: () => {}, + fileExists: () => false, + homeDir: () => "/fake-home", + }; + }; + // A clean "could not find" is absence... + expect(await macosAdapter(scriptRunner([{ ok: false, stderr: "Could not find service \"com.opencodex.proxy\"" }])).registrationState()).toBe("absent"); + // ...a manager error is unknown, and unknown must never authorize mutation. + expect(await macosAdapter(scriptRunner([{ ok: false, stderr: "Bootstrap failed: 5: Input/output error" }])).registrationState()).toBe("unknown"); + // A disabled systemd unit EXISTS — is-enabled exits 1 with "disabled". + expect(await linuxAdapter(scriptRunner([{ ok: false, stdout: "disabled" }])).registrationState()).toBe("present"); + expect(await linuxAdapter(scriptRunner([{ ok: false, stderr: "Failed to get unit file state: No such file or directory" }, { ok: false, stderr: "could not be found" }])).registrationState()).toBe("absent"); + // Windows probes both the task and the native WinSW service. + const windowsCalls: string[] = []; + const windowsRunner = { + run: async (spec: { file: string; args: string[] }): Promise => { + windowsCalls.push([spec.file, ...spec.args].join(" ")); + // Each manager reports absence in its own words. + const stderr = spec.file === "sc.exe" + ? "[SC] OpenService FAILED 1060: The specified service does not exist as an installed service." + : "ERROR: The system cannot find the file specified."; + return { ok: false, exitCode: 1, stdout: "", stderr }; + }, + mkdir: () => {}, + fileExists: () => false, + homeDir: () => "/fake-home", + }; + expect(await windowsAdapter(windowsRunner).registrationState()).toBe("absent"); + expect(windowsCalls.some(call => call.includes("opencodex-proxy-native"))).toBe(true); + expect(windowsCalls.some(call => call.includes("opencodex-proxy"))).toBe(true); + }); + + test("Windows and Linux tray automation is operator-supplied, never guessed", () => { + expect(windowsAdapter(okRunner).trayQuit()).toBeNull(); + expect(linuxAdapter(okRunner).trayQuit()).toBeNull(); + expect(macosAdapter(okRunner).trayQuit()).not.toBeNull(); + }); + + test("macOS tray actions are scoped to the OpenCodex process, not a global index", () => { + const quit = macosAdapter(okRunner).trayQuit(); + expect(quit?.args.join(" ")).toContain('process "OpenCodex"'); + }); + + test("every adapter can probe window visibility after a gesture", () => { + expect(macosAdapter(okRunner).windowVisible().file).toBe("osascript"); + expect(windowsAdapter(okRunner).windowVisible().file).toBe("powershell"); + expect(linuxAdapter(okRunner).windowVisible().file).toBe("xdotool"); + }); + + test("elevation cancellation is scoped to sighted pids", () => { + expect(linuxAdapter(okRunner).cancelElevation([])).toBeNull(); + const cancel = linuxAdapter(okRunner).cancelElevation([111, 222]); + expect(cancel?.file).toBe("kill"); + expect(cancel?.args).toEqual(["111", "222"]); + }); + + test("elevation monitoring watches pkexec AND the plugin's zenity/kdialog fallbacks", () => { + // The pinned updater falls back pkexec -> zenity/kdialog -> sudo after a cancel; + // a gate that watches only pkexec would call a retry "cancelled". + const probe = linuxAdapter(okRunner).elevationProbe(); + expect(probe?.args.join(" ")).toContain("pkexec"); + expect(probe?.args.join(" ")).toContain("zenity"); + expect(probe?.args.join(" ")).toContain("kdialog"); + expect(macosAdapter(okRunner).elevationProbe()).toBeNull(); + }); + + test("the deb version probe exists; AppImage updates are proven by digest instead", () => { + expect(linuxAdapter(okRunner).installedVersion("deb", "opencodex-desktop")?.file).toBe("dpkg-query"); + expect(linuxAdapter(okRunner).installedVersion("appimage")).toBeNull(); + }); + + test("process probes come from the adapter, so the engine stays platform-neutral", () => { + expect(macosAdapter(okRunner).appNameProbe().file).toBe("pgrep"); + expect(windowsAdapter(okRunner).appNameProbe().file).toBe("powershell"); + // Scoped probes bind to THIS install's path, so cleanup can never match a process + // from a different installation. + expect(linuxAdapter(okRunner).appProcessProbe("/gate/work/apps").args).toContain("/gate/work/apps"); + expect(linuxAdapter(okRunner).childPids(42).args).toContain("42"); + }); +}); + +describe("installed-gate sources carry no operator detail", () => { + const sources = [ + readFileSync(repoPath("desktop", "scripts", "installed-gate.ts"), "utf8"), + readFileSync(repoPath("desktop", "scripts", "installed-gate-platforms.ts"), "utf8"), + ]; + + test("no absolute user paths, host names or addresses appear in the drivers", () => { + for (const source of sources) { + expect(source).not.toMatch(/\/Users\/\w/); + expect(source).not.toMatch(/C:\\Users/); + // Loopback is the proxy's own listener, not operator detail; everything else is. + expect(source).not.toMatch(/\b(?!127\.)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/); + expect(source).not.toMatch(/\b\w+\.local\b/); + } + }); +}); + +describe("installed-artifact gate workflow", () => { + const text = readFileSync(repoPath(".github", "workflows", "desktop-installed-gate.yml"), "utf8"); + const workflow = Bun.YAML.parse(text) as { + on?: Record | string[]; + permissions?: Record; + jobs?: Record; + }>; + }; + const jobs = workflow.jobs ?? {}; + type Job = NonNullable[string] & { + environment?: string; + steps?: Array<{ name?: string; uses?: string; run?: string; if?: string; with?: Record }>; + }; + + test("it is dispatch-only: a stateful GUI machine must never run because a push happened", () => { + const triggers = Array.isArray(workflow.on) ? workflow.on : Object.keys(workflow.on ?? {}); + expect(triggers).toEqual(["workflow_dispatch"]); + }); + + test("least privilege: read-only contents and nothing else", () => { + expect(workflow.permissions).toEqual({ contents: "read" }); + }); + + test("every platform job targets a self-hosted gate runner with a bounded timeout", () => { + const gateJobs = Object.entries(jobs).filter(([name]) => name !== "report"); + expect(gateJobs.map(([name]) => name)).toEqual(["macos", "windows", "linux"]); + for (const [, job] of gateJobs) { + const runsOn = Array.isArray(job["runs-on"]) ? job["runs-on"] : [job["runs-on"]]; + expect(runsOn).toContain("self-hosted"); + expect(runsOn?.some(label => typeof label === "string" && label.startsWith("opencodex-gate-"))).toBe(true); + expect(job["timeout-minutes"]).toBeGreaterThanOrEqual(30); + expect(job["timeout-minutes"]).toBeLessThanOrEqual(120); + } + }); + + test("the gate report is uploaded even when the gate failed", () => { + for (const [name, job] of Object.entries(jobs)) { + const uploads = (job.steps ?? []).filter(step => step.uses?.startsWith("actions/upload-artifact@")); + expect(uploads.length, `${name} must upload its report`).toBe(1); + expect(uploads[0]?.if).toContain("always()"); + } + }); + + test("all actions are pinned to full commit SHAs", () => { + // Parse every uses: value rather than pattern-matching a few known-bad refs: + // a short SHA or an arbitrary branch name is not an immutable pin either. + const usesValues: string[] = []; + for (const job of Object.values(jobs)) { + for (const step of job.steps ?? []) { + if (step.uses) usesValues.push(step.uses); + } + } + expect(usesValues.length).toBeGreaterThan(0); + for (const uses of usesValues) { + const isLocal = uses.startsWith("./"); + const pinnedSha = /^[^@]+@([0-9a-f]{40})$/.exec(uses); + expect(isLocal || pinnedSha !== null, `${uses} must be a local action or a full-SHA pin`).toBe(true); + } + }); + + test("GUI automation inputs are hook names, never command text", () => { + const dispatch = (workflow.on as Record }>).workflow_dispatch; + const inputNames = Object.keys(dispatch?.inputs ?? {}); + expect(inputNames).toContain("consent-hook"); + expect(inputNames).toContain("tray-quit-hook"); + expect(inputNames).toContain("elevate-accept-hook"); + for (const name of inputNames) { + expect(name).not.toMatch(/command$/); + } + }); + + test("the npm package is never constructed from raw input", () => { + // The driver derives the spec from the repository's own package.json; a workflow + // that interpolates a package name would reopen npm alias injection. + expect(text).not.toContain("--npm-package"); + }); + + test("every gate job sits behind a required-review environment and checks out protected dev", () => { + // The runners install software and hold sudo; the workflow must never execute a + // dispatcher-selected ref on them. + for (const [name, job] of Object.entries(jobs) as Array<[string, Job]>) { + expect(job.environment, `${name} must declare the gated environment`).toBe("opencodex-desktop-gate"); + const checkout = (job.steps ?? []).find(step => step.uses?.startsWith("actions/checkout@")); + expect(checkout?.with?.ref, `${name} must check out the protected integration branch`).toBe("dev"); + } + }); + + test("dispatch inputs reach shell code through env, never by interpolation", () => { + for (const job of Object.values(jobs)) { + for (const step of job.steps ?? []) { + if (typeof step.run === "string") { + expect(step.run).not.toContain("${{ inputs."); + expect(step.run).not.toContain("${{ github.event.inputs."); + } + } + } + }); + + test("linux runs both update formats through the gate", () => { + const linux = jobs.linux as { strategy?: { matrix?: { format?: string[] } } } | undefined; + expect(linux?.strategy?.matrix?.format).toEqual(["deb", "appimage"]); + }); +}); diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 15c6cbe3a18..e36c5ac5ed1 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -130,6 +130,7 @@ describe("desktop release scripts", () => { try { writeFileSync(join(root, "OpenCodex-2.61.0-macos.app.tar.gz.sig"), "mac-signature\n"); writeFileSync(join(root, "OpenCodex-2.61.0-windows-x64.msi.sig"), "win-signature\n"); + writeFileSync(join(root, "OpenCodex-2.61.0-linux-x86_64.AppImage.sig"), "appimage-signature\n"); const warnings: string[] = []; const manifest = buildUpdaterManifest({ version: "2.61.0", @@ -152,9 +153,15 @@ describe("desktop release scripts", () => { signature: "win-signature", url: "https://github.com/lidge-jun/opencodex/releases/download/v2.61.0/OpenCodex-2.61.0-windows-x64.msi", }, + // The AppImage keeps the plugin's default Linux key so already-released AppImage + // installs keep resolving their updates; deb installs select the explicit key. + "linux-x86_64": { + signature: "appimage-signature", + url: "https://github.com/lidge-jun/opencodex/releases/download/v2.61.0/OpenCodex-2.61.0-linux-x86_64.AppImage", + }, }); expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("linux-x86_64"); + expect(warnings[0]).toContain("linux-x86_64-deb"); } finally { rmSync(root, { recursive: true, force: true }); } @@ -183,6 +190,7 @@ describe("desktop release scripts", () => { try { writeFileSync(join(root, "OpenCodex-2.61.0-macos.app.tar.gz.sig"), "mac-signature\n"); writeFileSync(join(root, "OpenCodex-2.61.0-windows-x64.msi.sig"), "win-signature\n"); + writeFileSync(join(root, "OpenCodex-2.61.0-linux-x86_64.AppImage.sig"), "appimage-signature\n"); expect(() => buildUpdaterManifest({ @@ -192,7 +200,7 @@ describe("desktop release scripts", () => { out: join(root, "latest.json"), requireAll: true, }), - ).toThrow("Missing signed updater platforms: linux-x86_64"); + ).toThrow("Missing signed updater platforms: linux-x86_64-deb"); } finally { rmSync(root, { recursive: true, force: true }); } @@ -208,6 +216,30 @@ describe("desktop release scripts", () => { * an extension signed that way, so the app would have installed with no widget and nothing in * the build would have said so. */ +describe("the desktop build toolchain carries the bundle-type marker", () => { + // updater.rs selects the deb updater target from tauri_utils::platform::bundle_type(), + // which reads a marker the tauri-bundler patches into the binary at packaging time. + // Bundlers before 2.5.0 (tauri-cli < 2.7.0) never patch: every packaged artifact then + // reports "unknown" and a deb install would resolve the AppImage payload it cannot + // apply. Verified statically at tag tauri-cli-v2.11.1: crates/tauri-bundler/src/ + // bundle.rs maps Deb and AppImage to their marker values, patches per package type, + // signs after patching, and restores the unpatched binary between formats. + const minimumCliWithBundlePatch = { major: 2, minor: 7 }; + + test("the pinned Tauri CLI is new enough to patch the bundle type into each Linux artifact", () => { + const manifest = JSON.parse(readFileSync(repoPath("desktop", "package.json"), "utf8")) as { + devDependencies?: Record; + }; + const version = manifest.devDependencies?.["@tauri-apps/cli"]; + expect(version).toBeDefined(); + const [major, minor] = version!.split(".").map(Number); + expect( + major! > minimumCliWithBundlePatch.major + || (major === minimumCliWithBundlePatch.major && minor! >= minimumCliWithBundlePatch.minor), + ).toBe(true); + }); +}); + describe("widget extension signing", () => { const script = readFileSync(repoPath("desktop", "scripts", "build-widget.sh"), "utf8"); const workflow = Bun.YAML.parse( diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bccdcc68608..3d86c8b88af 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "release-desktop-scripts.test.ts": "ci-workflows", + "installed-gate-drivers.test.ts": "ci-workflows", "gui-desktop-sidecar-script.test.ts": "gui", "standalone-build-script.test.ts": "gui", "standalone-service.test.ts": "service", From e2453085b9749c1c38217a32601f01eb817fdaa1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:55:54 +0900 Subject: [PATCH 69/87] test(release): take the asset basename the way the platform spells it (#5404) --- tests/ci-workflows/release-desktop-scripts.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index e36c5ac5ed1..18b37d41793 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; import { repoPath } from "../helpers/repo-root"; @@ -39,7 +39,10 @@ describe("desktop release scripts", () => { repoRoot: root, }); - expect(files.map(path => path.split("/").at(-1))).toEqual([ + // The paths come back from `join`, so on Windows they are separated by backslashes and a + // "/" split returns the whole path. Asking the platform for the last segment keeps this + // assertion about the asset names it is written to check. + expect(files.map(path => basename(path))).toEqual([ "OpenCodex-2.61.0-macos.dmg", "OpenCodex-2.61.0-macos.dmg.sha256", "OpenCodex-2.61.0-macos.app.tar.gz", @@ -79,7 +82,7 @@ describe("desktop release scripts", () => { repoRoot: root, }); - expect(files.map(path => path.split("/").at(-1))).toEqual([ + expect(files.map(path => basename(path))).toEqual([ "OpenCodex-2.61.0-windows-x64.msi", "OpenCodex-2.61.0-windows-x64.msi.sig", "OpenCodex-2.61.0-windows-x64.msi.sha256", From 3b1fdd8d8bcc00dd867b2082ab2e5aff0ccde706 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:06:09 +0900 Subject: [PATCH 70/87] fix(desktop): make the Windows shell load its own origin and hide the console (#5399) * fix(desktop): make the Windows shell load its own origin and hide the console Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(desktop): let Stop proxy settle before deciding whether the proxy is gone Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(desktop): only trust http for the Windows app origin and log a stuck Stop proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: jun Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/README.md | 5 +++-- desktop/scripts/build-local.ts | 27 +++++++++++++++++------ desktop/src-tauri/src/main.rs | 2 ++ desktop/src-tauri/src/tray.rs | 11 +++++++++- desktop/src-tauri/src/window.rs | 38 +++++++++++++++++++++++++++++++-- 5 files changed, 72 insertions(+), 11 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 02e793a4766..153293a55da 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -37,8 +37,9 @@ misleading on a workstation. bun run build:local ``` -This asks for the app and dmg only, so no updater archive is produced and none is expected to be -signed. It prints the bundle path and exits zero. The release path below is unchanged: a published +This asks for the host platform's installable bundles only (app and dmg on macOS, msi and nsis +setup exe on Windows, AppImage and deb on Linux), so no updater archive is produced and none is +expected to be signed. It prints the bundle paths and exits zero. The release path below is unchanged: a published updater artifact still has to be signed. ## Release packaging and updates diff --git a/desktop/scripts/build-local.ts b/desktop/scripts/build-local.ts index 633a27b6c15..2616c7fc509 100644 --- a/desktop/scripts/build-local.ts +++ b/desktop/scripts/build-local.ts @@ -21,14 +21,24 @@ * `OpenCodex.app.tar.gz (updater)` and still fails. The override has to reach the config itself. */ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); -/** Bundle targets that carry no updater archive. */ -const LOCAL_BUNDLES = ["app", "dmg"] as const; +/** Bundle targets per host platform that carry no updater archive. */ +const LOCAL_BUNDLES: Record = { + darwin: ["app", "dmg"], + win32: ["msi", "nsis"], + linux: ["appimage", "deb"], +}; + +const bundles = LOCAL_BUNDLES[process.platform]; +if (!bundles) { + console.error(`[build:local] unsupported host platform: ${process.platform}`); + process.exit(1); +} /** * Config merged over `tauri.conf.json` for this invocation only. @@ -43,7 +53,7 @@ function run(): number { const extra = process.argv.slice(2); const args = [ "tauri", "build", "--ci", - "--bundles", LOCAL_BUNDLES.join(","), + "--bundles", bundles.join(","), "--config", LOCAL_CONFIG, ...extra, ]; @@ -58,10 +68,15 @@ function run(): number { const status = run(); if (status === 0) { const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); - const app = join(bundleRoot, "macos", "OpenCodex.app"); // Naming what exists is the point of the script: the previous output ended on an error line, so // the artifacts it had already written were the least visible thing in it. - if (existsSync(app)) console.log(`[build:local] ${app}`); + for (const dir of ["macos", "dmg", "msi", "nsis", "appimage", "deb"]) { + const directory = join(bundleRoot, dir); + if (!existsSync(directory)) continue; + for (const name of readdirSync(directory)) { + if (/\.(app|dmg|msi|exe|AppImage|deb)$/i.test(name)) console.log(`[build:local] ${join(directory, name)}`); + } + } console.log("[build:local] updater artifacts skipped; release signing is unchanged."); } process.exit(status); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index c0e7716829b..8d5174cf6fa 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + fn main() { opencodex_desktop_lib::run(); } diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index 56f25506457..5e5e818b182 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -126,10 +126,19 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { let app = app.clone(); let stop_item = stop_item.clone(); tauri::async_runtime::spawn(async move { - let stopped = proxy.stop().await.is_ok() || proxy.is_alive().await.is_err(); + let mut stopped = proxy.stop().await.is_ok(); + for _ in 0..10 { + if stopped || proxy.is_alive().await.is_err() { + stopped = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } if stopped { app.state::().shutdown_child(); let _ = stop_item.set_enabled(false); + } else { + eprintln!("tray: proxy still answering /healthz after stop request"); } }); } diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index f9550247ae3..10a1425dec1 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -25,7 +25,7 @@ pub fn configure(window: &WebviewWindow) { pub fn navigation_allowed(endpoint: ProxyEndpoint) -> impl Fn(&Url) -> bool { move |url| { - if url.scheme() == "tauri" { + if is_app_origin(url) { return true; } if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { @@ -39,6 +39,17 @@ pub fn navigation_allowed(endpoint: ProxyEndpoint) -> impl Fn(&Url) -> bool { } } +/// The bundled `frontendDist` origin. Tauri serves it as `tauri://localhost` on macOS and +/// Linux, and as `http://tauri.localhost` on Windows, where WebView2 has no custom-scheme +/// support. +fn is_app_origin(url: &Url) -> bool { + match url.scheme() { + "tauri" => true, + "http" => url.host_str() == Some("tauri.localhost"), + _ => false, + } +} + pub fn show(window: &WebviewWindow) { let _ = window.show(); let _ = window.set_focus(); @@ -70,7 +81,30 @@ pub fn set_tray_policy(app: &AppHandle, visible: bool) { #[cfg(test)] mod tests { - use super::webview_user_agent; + use super::{is_app_origin, navigation_allowed, webview_user_agent}; + use crate::discovery::ProxyEndpoint; + use tauri::Url; + + #[test] + fn navigation_allows_the_app_origin_on_every_platform() { + let allowed = navigation_allowed(ProxyEndpoint { + host: "127.0.0.1", + port: 10100, + }); + assert!(allowed( + &Url::parse("tauri://localhost/index.html?port=10100").unwrap() + )); + assert!(allowed( + &Url::parse("http://tauri.localhost/index.html?port=10100").unwrap() + )); + assert!(allowed( + &Url::parse("http://127.0.0.1:10100/#/usage").unwrap() + )); + assert!(!is_app_origin( + &Url::parse("https://tauri.localhost/index.html").unwrap() + )); + assert!(!allowed(&Url::parse("file:///C:/index.html").unwrap())); + } #[test] fn webview_user_agent_marks_the_desktop_shell() { From 8b8783bdd21dc0883e0423169b871e0a092fd835 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 13:15:46 +0900 Subject: [PATCH 71/87] Close the review findings on runtime ownership: one install-state contract, locked resolution, atomic replace (#5400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(service): one install-state contract for both runtimes The record shape, the state-path list and the ownership resolution move into a plain-ESM module both runtimes import. They validated the record separately before, and the Node launcher's copy was weaker in two ways that decided authorization: it inspected only the anchor path, and it answered 'known unowned' for any record whose ownership field was absent, including one that fails the contract outright. Reported on PR #5386 by Codex (P2) and CodeRabbit (major, CWE-863). * fix(service): close the three state-write defects review found Ownership is now resolved INSIDE the swap, while the anchor lock is held. Resolving beforehand was a lost update the compare-and-swap cannot detect: a takeover landing between the resolution and the base read reaches current, passes the revision check untouched, and is then overwritten by the older claim. Where the anchor and the cross-path resolution still disagree, the higher consent generation wins and an equal generation keeps the anchor. Each state file is published by writing a sibling temporary file and renaming it. An in-place write truncates first, so an interrupted commit left the anchor empty or half-serialized — which the fail-closed reader introduced in #5386 reports as unknown, blocking start, repair, restart and every update until a takeover install. Windows can refuse the replace transiently, so it retries and then falls back to the in-place write rather than failing an install that already registered. The lock file carries a token naming its holder. Eviction re-reads it before unlinking and release unlinks only its own instance, so a holder evicted as stale can no longer delete the replacement lock and hand a third writer the pathname. The stale threshold now exceeds the longest legitimate critical section rather than the typical one: on Windows a commit runs hardenSecretPath per path, whose own worst case is far beyond the previous thirty seconds. The reader and the parser delegate to the shared contract. * fix(update): share the contract and re-read ownership at each runtime action The launcher resolves ownership through the shared contract across every state path instead of its own anchor-only reader, and that reader is deleted rather than kept in step by a table of claim shapes — being 'in step' is what it was not. Both package updaters re-read the claim immediately before the stop rather than trusting a plan formed earlier in the run. The Windows tray handoff spawns children between the two, so a takeover can land in the gap, and stopping a runtime that just changed hands is the failure this lane exists to prevent. * docs: document runtime ownership, its refusals and the recovery path The CLI lifecycle reference gains a Runtime ownership section: what the record means, which subcommands refuse under a foreign or unknown owner and which stay open, what the refusal text says, and that ocx service install is the never-gated recovery. Codex flagged the missing page on PR #5386 against the docs rule in src/AGENTS.md. structure/runtime.md records the shared contract module, the resolve-inside-lock rule, the atomic replace, the lock token, and the residual window that re-reading narrows but does not remove. * test: cover the shared contract, the atomic replace and the lock token The launcher's parser is gone, so the table that kept two readers aligned is replaced by cases against the one contract, including the record that fails it while carrying no ownership field — the exact shape the launcher used to call unowned. Adds a legacy-path claim, a cross-path conflict, an unreadable path, the staging-and-rename write path, the lock token's release rule, and the newer-claim tie-break. * test(update): follow the launcher's state-path list to the shared contract The launcher no longer spells "service-state.json": the path list moved into the contract both runtimes import, which is the point of the change. The assertion now reads the call that builds that list and the detection it feeds, so it still proves the launcher decides service-installed from the install-state record. Caught by hosted CI on shard 1/4 at 4017e7d8ee; shards 2/4, 3/4 and 4/4 were green. --- bin/ocx.mjs | 52 ++-- .../content/docs/reference/cli/lifecycle.md | 44 +++ src/service/install-state-contract.d.mts | 24 ++ src/service/install-state-contract.mjs | 141 ++++++++++ src/service/state.ts | 257 ++++++++++-------- src/update/index.ts | 15 +- src/update/runtime-ownership.d.mts | 6 - src/update/runtime-ownership.mjs | 36 +-- structure/runtime.md | 37 ++- tests/service/service-ownership-state.test.ts | 82 +++++- tests/update/update-desktop-owner.test.ts | 106 +++++++- tests/update/update-stop-first.test.ts | 9 +- 12 files changed, 616 insertions(+), 193 deletions(-) create mode 100644 src/service/install-state-contract.d.mts create mode 100644 src/service/install-state-contract.mjs diff --git a/bin/ocx.mjs b/bin/ocx.mjs index f7cce9523df..1603e370416 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -12,7 +12,12 @@ import { spawn, spawnSync } from "node:child_process"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; -import { parseRecordedOwnership, planUpdateRuntimeHandling } from "../src/update/runtime-ownership.mjs"; +import { planUpdateRuntimeHandling } from "../src/update/runtime-ownership.mjs"; +import { + inspectInstallStateBytes, + resolveOwnershipFromEvidence, + serviceStateFilesFor, +} from "../src/service/install-state-contract.mjs"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -257,29 +262,26 @@ function runPackageManagerSelfUpdate(manager) { // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` // unloads it, so a successful update must refresh and restart it afterwards. - const serviceStatePath = join(configDir(), "service-state.json"); + const serviceStateFiles = serviceStateFilesFor(configDir(), join(homedir(), ".opencodex")); + const serviceStatePath = serviceStateFiles[0]; const serviceWasInstalled = existsSync(serviceStatePath); - // What this update may do to the runtime. The same rule the Bun updater applies, from the - // same module: a desktop takeover vetoes both the stop and the service refresh below. + /** + * What this update may do to the runtime, decided by the SAME contract the Bun updater + * uses — every state path, the whole record shape, and absence as the only answer that + * means no claim. + * + * This used to be a local reader that inspected the anchor alone and returned "known + * unowned" whenever the `ownership` field was simply missing, including from a record that + * fails the contract outright. A takeover the Bun updater refused to disturb was therefore + * fair game here, which is an authorization gap rather than a cosmetic divergence. + */ const readOwnership = () => { - if (!existsSync(serviceStatePath)) return { ownership: null, ownershipUnknown: false }; - try { - const raw = readFileSync(serviceStatePath, "utf8"); - // Fails CLOSED on a record that exists but does not parse: unreadable and malformed - // are not "nobody owns it", and reading them that way reactivates the npm launcher - // over a takeover the user consented to. - if (!raw.trim()) return { ownership: null, ownershipUnknown: true }; - let parsed; - try { parsed = JSON.parse(raw); } catch { return { ownership: null, ownershipUnknown: true }; } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ownership: null, ownershipUnknown: true }; - if (parsed.ownership === undefined) return { ownership: null, ownershipUnknown: false }; - const ownership = parseRecordedOwnership(raw); - return ownership ? { ownership, ownershipUnknown: false } : { ownership: null, ownershipUnknown: true }; - } catch { - return { ownership: null, ownershipUnknown: true }; - } + const evidence = serviceStateFiles.map(path => inspectInstallStateBytes(path, at => readFileSync(at, "utf8"))); + const resolution = resolveOwnershipFromEvidence(evidence); + if (resolution.kind === "owned") return { ownership: resolution.ownership, ownershipUnknown: false }; + return { ownership: null, ownershipUnknown: resolution.kind === "unknown" }; }; - const runtimePlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + let runtimePlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); if (runtimePlan.notice) console.log(runtimePlan.notice); const trayBeforeUpdate = planWindowsTrayUpdate( process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, @@ -516,6 +518,14 @@ function runPackageManagerSelfUpdate(manager) { // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides // whether the obligation is safe to finish. const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); + // Re-read at the point of action rather than trusting the plan formed above: the Windows + // tray handoff between them spawns children, so a takeover can land in the gap, and + // stopping a runtime that just changed hands is the failure this lane exists to prevent. + { + const atStop = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + if (atStop.notice && atStop.notice !== runtimePlan.notice) console.log(atStop.notice); + runtimePlan = atStop; + } if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)) { console.log("⏹ Stopping the running proxy before updating..."); const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true }); diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 1118d02985f..3473d3b19cc 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -448,6 +448,50 @@ On Windows, a bare `ocx service` runs the install path only after both Task Sche proven absent. If either status query is inconclusive, it refuses to register anything and asks you to run `ocx service status`; use explicit `ocx service install` only after confirming absence. +### Runtime ownership + +The OpenCodex desktop app can take the background proxy over from a CLI installation. When it does, +it records the handover in the shared service install state, and that record is what makes the +takeover survive a restart. Your service registration is **kept, never deleted** — the record +supersedes it rather than replacing it. + +A state file with no ownership record means the CLI installation owns the runtime, which is what +every installation made before this feature is in. Nothing changes for you until an app takes over. + +While something other than this CLI owns the runtime, the subcommands that would **activate** your +registration refuse instead: + +| Subcommand | Behaviour under a foreign owner | +| --- | --- | +| `repair`, `restart` | Refuse before changing anything. The registration is not re-enabled, rewritten or restarted. | +| `start` | Refuses for the same reason, so an automatic tray start cannot put a second proxy beside the app's. | +| `stop`, `uninstall` | Unchanged. They deactivate, so they are never gated. | +| `install` | Takes the runtime back. It clears the ownership record after the registration succeeds, and reports whose it was. | + +`ocx update` behaves the same way: it neither stops the running proxy nor refreshes the service +while the app owns the runtime, because the running server is the app's own bundled binary and the +refresh would re-enable the launcher the takeover superseded. The app updates its own runtime. + +The refusal names the owning installation and the consent generation, for example: + +```text +Background service repair stopped: the desktop app owns the runtime (install , consent generation 2). +The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted. +Quit the desktop app and run 'ocx service install' to hand the runtime back to this CLI. +``` + +A record that cannot be read or does not parse produces the same refusal with a different first +line, because an unreadable claim is not the same as no claim — treating it as "nobody owns this" +is how a permissions error would silently reactivate your service. + +**Recovery in every case is `ocx service install`.** It is deliberately the one verb that is never +gated, so removing the app without handing the runtime back, or a corrupted state file, still leaves +you a way to take the service back: + +```bash +ocx service install +``` + ```bash ocx service ocx service install diff --git a/src/service/install-state-contract.d.mts b/src/service/install-state-contract.d.mts new file mode 100644 index 00000000000..96a1fb7ae85 --- /dev/null +++ b/src/service/install-state-contract.d.mts @@ -0,0 +1,24 @@ +/** Declaration for the plain-ESM install-state contract shared with `bin/ocx.mjs`. */ +export type OwnershipClaim = { owner: string; installId: string; consentGeneration: number }; + +export type InstallStateEvidence = + | { path: string; kind: "absent" } + | { path: string; kind: "unreadable"; reason: string } + | { path: string; kind: "invalid" } + | { path: string; kind: "valid"; state: unknown }; + +export type OwnershipResolution = + | { kind: "none" } + | { kind: "owned"; ownership: OwnershipClaim } + | { kind: "unknown"; reason: string }; + +export declare const SERVICE_STATE_FILE: string; +export declare function parseOwnershipClaim(value: unknown): OwnershipClaim | null; +/** Returns the validated record, or null. Typed loosely so each runtime applies its own shape. */ +export declare function parseInstallStateRecord(value: unknown): unknown; +export declare function inspectInstallStateBytes(path: string, read: (path: string) => string): InstallStateEvidence; +export declare function resolveOwnershipFromEvidence( + evidence: readonly { path: string; kind: string; reason?: string; state?: unknown }[], +): OwnershipResolution; +export declare function serviceStateFilesFor(opencodexHomeDir: string, defaultHomeDir: string): string[]; + diff --git a/src/service/install-state-contract.mjs b/src/service/install-state-contract.mjs new file mode 100644 index 00000000000..b07cb7cc901 --- /dev/null +++ b/src/service/install-state-contract.mjs @@ -0,0 +1,141 @@ +/** + * The service install-state contract, shared by both runtimes. + * + * `src/service/state.ts` is the authoritative reader and `bin/ocx.mjs` is the Node launcher + * that cannot import TypeScript. They used to validate the record separately, and the + * launcher's copy was weaker in two ways that mattered: it inspected only the anchor path, + * and it returned "known unowned" for any record whose `ownership` field was simply absent — + * including a record that fails the contract outright, such as one with no homes or an + * unsupported version. A takeover the Bun updater refused to disturb was therefore fair game + * for the npm and pnpm lane. + * + * This module is the one algorithm. Both sides import it, so the two lanes cannot answer the + * same question differently. + */ +import { join, resolve } from "node:path"; + +function isNonNegativeInteger(value) { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +/** + * Validate an ownership claim read off disk. + * + * Returns the ORIGINAL object rather than a rebuilt one: a newer writer may carry fields + * this version does not know about, and rebuilding would drop them on the next preserve — + * the same lost-field failure the record exists to stop. + */ +export function parseOwnershipClaim(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if (value.owner !== "cli" && value.owner !== "desktop") return null; + if (typeof value.installId !== "string" || value.installId.length === 0) return null; + if (!isNonNegativeInteger(value.consentGeneration)) return null; + return value; +} + +/** Validate a whole install record. Null means the bytes are not a record this tree wrote. */ +export function parseInstallStateRecord(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if (value.version !== 1 && value.version !== 2) return null; + if (typeof value.codexHome !== "string" || value.codexHome.length === 0) return null; + if (typeof value.opencodexHome !== "string" || value.opencodexHome.length === 0) return null; + for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"]) { + if (value[key] !== undefined && (typeof value[key] !== "string" || value[key].length === 0)) return null; + } + // cliPath is the one path that is legitimately null: cliEntry() returns null for a + // standalone binary, and the writer stores it. + if (value.cliPath !== undefined && value.cliPath !== null + && (typeof value.cliPath !== "string" || value.cliPath.length === 0)) return null; + if (value.revision !== undefined && !isNonNegativeInteger(value.revision)) return null; + if (value.consentGenerationCeiling !== undefined && !isNonNegativeInteger(value.consentGenerationCeiling)) return null; + // A malformed ownership claim invalidates the whole record instead of being dropped: + // silently discarding it is precisely the demotion this field exists to prevent, and a + // reader that cannot trust the claim must not be told the runtime is unowned. + if (value.ownership !== undefined && parseOwnershipClaim(value.ownership) === null) return null; + if (value.version === 1) { + if (value.backend !== undefined) return null; + } else if (value.backend !== "scheduler" && value.backend !== "native") { + return null; + } + return value; +} + +/** + * Classify one state path's bytes. `read` returns the text, or throws; an ENOENT throw is + * absence and every other throw is a failure to ask. + * + * Absent, unreadable and invalid are three different answers. Collapsing them is how a + * locked-down or truncated record becomes permission to reactivate the npm launcher. + */ +export function inspectInstallStateBytes(path, read) { + let raw; + try { + raw = read(path); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") return { path, kind: "absent" }; + return { path, kind: "unreadable", reason: code || String(error) }; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { path, kind: "invalid" }; + } + const state = parseInstallStateRecord(parsed); + return state ? { path, kind: "valid", state } : { path, kind: "invalid" }; +} + +/** + * What every state path, together, says about who owns the runtime. + * + * An unknown resolution is the answer that matters. A single null for "absent, unreadable or + * malformed" lets a caller read a permissions error as "the CLI owns it" and re-enable the + * npm launcher over a consented takeover. Absence is the only thing that may mean no claim. + */ +export function resolveOwnershipFromEvidence(evidence) { + for (const entry of evidence) { + // Any path. A claim we are not allowed to look at is still a claim. + if (entry.kind === "unreadable") { + return { kind: "unknown", reason: `a service state path could not be read (${entry.reason})` }; + } + } + // Only the ANCHOR's corruption is fatal. The second path is the legacy default-home entry + // kept so an install made before OPENCODEX_HOME existed can still be found; unrelated junk + // left there by an old version must not be able to block every repair on this machine. + if (evidence[0] && evidence[0].kind === "invalid") { + return { kind: "unknown", reason: "the service install record is present but not valid" }; + } + const claims = []; + for (const entry of evidence) { + if (entry.kind === "valid" && entry.state.ownership) claims.push(entry.state.ownership); + } + const first = claims[0]; + if (first === undefined) return { kind: "none" }; + if (claims.some(claim => claim.owner !== first.owner || claim.installId !== first.installId)) { + return { kind: "unknown", reason: "the service state paths name different owners" }; + } + // Same claim in both places; the higher generation is the later write. + let best = first; + for (const claim of claims) if (claim.consentGeneration > best.consentGeneration) best = claim; + return { kind: "owned", ownership: best }; +} + +export const SERVICE_STATE_FILE = "service-state.json"; + +/** + * The state files to consult, in the order every reader resolves them: this OpenCodex home + * first, then the legacy default home kept for installs made before OPENCODEX_HOME existed. + * + * Shared so the launcher cannot inspect a shorter list than the authoritative reader — which + * it did, seeing only the anchor and never the legacy claim beside it. + */ +export function serviceStateFilesFor(opencodexHomeDir, defaultHomeDir) { + const anchor = join(opencodexHomeDir, SERVICE_STATE_FILE); + const legacy = join(defaultHomeDir, SERVICE_STATE_FILE); + const same = process.platform === "win32" + ? resolve(anchor).toLowerCase() === resolve(legacy).toLowerCase() + : resolve(anchor) === resolve(legacy); + return same ? [anchor] : [anchor, legacy]; +} + diff --git a/src/service/state.ts b/src/service/state.ts index 65b7bd85616..f4b75753636 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -1,4 +1,5 @@ -import { accessSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { accessSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; @@ -10,6 +11,13 @@ import { hardenSecretPath } from "../lib/windows-secret-acl"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isProtectedHomeUnderTest, isTestHomeGuardArmed } from "../lib/test-home-guard"; import { isStandaloneBinary } from "../lib/standalone"; +import { + inspectInstallStateBytes, + parseInstallStateRecord, + parseOwnershipClaim, + resolveOwnershipFromEvidence, + serviceStateFilesFor, +} from "./install-state-contract.mjs"; /** * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in @@ -135,10 +143,9 @@ function defaultOpenCodexHome(): string { } export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] { - const paths = [join(opencodexHome, "service-state.json")]; - const defaultPath = join(defaultOpenCodexHome(), "service-state.json"); - if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath); - return paths; + // Shared with the Node launcher, which has to consult the SAME list: reading only the + // anchor is how it missed a claim recorded on the legacy default-home path. + return serviceStateFilesFor(opencodexHome, defaultOpenCodexHome()); } export function serviceStatePaths(): string[] { @@ -296,10 +303,6 @@ export interface ServiceOwnership { readonly consentGeneration: number; } -function isNonNegativeInteger(value: unknown): value is number { - return typeof value === "number" && Number.isInteger(value) && value >= 0; -} - /** * Validate an ownership claim read off disk. * @@ -308,42 +311,17 @@ function isNonNegativeInteger(value: unknown): value is number { * which is the same lost-field failure this whole record exists to stop. */ export function parseServiceOwnership(value: unknown): ServiceOwnership | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const ownership = value as Record; - if (ownership.owner !== "cli" && ownership.owner !== "desktop") return null; - if (typeof ownership.installId !== "string" || ownership.installId.length === 0) return null; - if (!isNonNegativeInteger(ownership.consentGeneration)) return null; - return value as ServiceOwnership; + return parseOwnershipClaim(value) as ServiceOwnership | null; } +/** + * The record contract lives in `install-state-contract.mjs` so the Node launcher validates + * exactly what this reader validates. It used to keep a weaker copy, and a record that fails + * this contract while merely lacking an `ownership` field read there as "nobody owns the + * runtime" — which is permission to stop a foreign runtime and reactivate the npm service. + */ export function parseServiceInstallState(value: unknown): ServiceInstallState | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const state = value as Record; - if (state.version !== 1 && state.version !== 2) return null; - if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null; - if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null; - for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"] as const) { - if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null; - } - // `cliPath` is the one path that is legitimately null: `cliEntry()` returns null for a - // standalone binary, and the writer stores it. Validating it as a non-empty string made - // every standalone install write a record its OWN parser rejected — which reads back as - // "no install state", and after ownership moved into this record, as "nobody owns the - // runtime". Pre-existing; found while making ownership fail closed. - if (state.cliPath !== undefined && state.cliPath !== null - && (typeof state.cliPath !== "string" || state.cliPath.length === 0)) return null; - if (state.revision !== undefined && !isNonNegativeInteger(state.revision)) return null; - if (state.consentGenerationCeiling !== undefined && !isNonNegativeInteger(state.consentGenerationCeiling)) return null; - // A malformed ownership claim invalidates the whole record instead of being dropped: - // silently discarding it is precisely the demotion this field is here to prevent, and a - // reader that cannot trust the claim must not be told the runtime is unowned. - if (state.ownership !== undefined && parseServiceOwnership(state.ownership) === null) return null; - if (state.version === 1) { - if (state.backend !== undefined) return null; - } else if (state.backend !== "scheduler" && state.backend !== "native") { - return null; - } - return state as unknown as ServiceInstallState; + return parseInstallStateRecord(value) as ServiceInstallState | null; } /** @@ -380,17 +358,21 @@ function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string * the consent durable. */ export function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void { - // Resolved across every state path, so a claim living only on the legacy mirror is carried - // onto the anchor rather than lost the first time this home writes. - // - // This does NOT refuse on an unknown resolution. It runs at the END of a successful install - // or repair, where a throw would report a service that is registered and running as a - // failure. The fail-closed decision belongs in front of the mutation, where repair and the - // updaters make it; here the job is to preserve as much as can be read. - const resolution = resolveServiceOwnership(); swapServiceInstallState(current => ({ ...installProvenanceRecord(backend, launcherPath), - ...preservedConsent(current, resolution), + // Resolved INSIDE the swap, which runs while the anchor lock is held, and across every + // state path so a claim living only on the legacy mirror is carried onto the anchor. + // + // Resolving before the lock was a lost-update window of its own: a takeover recorded + // between the resolution and the swap's base read lands in `current`, passes the revision + // check untouched, and is then overwritten by the older claim this function captured. + // The compare-and-swap cannot see that, because the stale value never came from the base. + // + // This does NOT refuse on an unknown resolution. It runs at the END of a successful + // install or repair, where a throw would report a service that is registered and running + // as a failure. The fail-closed decision belongs in front of the mutation, where repair + // and the updaters make it; here the job is to preserve as much as can be read. + ...preservedConsent(current, resolveServiceOwnership()), })); } @@ -399,7 +381,16 @@ function preservedConsent( current: ServiceInstallState | null, resolution: ServiceOwnershipResolution, ): Pick { - const ownership = resolution.kind === "owned" ? resolution.ownership : current?.ownership; + // Both inputs are read under the lock, and they can still disagree: `current` is the anchor + // alone, the resolution spans every path. Never let the older grant win, and on an equal + // generation keep `current` — the anchor is the record every reader resolves first, so + // preferring it is the fail-safe tie. + const resolved = resolution.kind === "owned" ? resolution.ownership : undefined; + const ownership = resolved === undefined + ? current?.ownership + : current?.ownership && current.ownership.consentGeneration >= resolved.consentGeneration + ? current.ownership + : resolved; const ceiling = Math.max(current?.consentGenerationCeiling ?? 0, ownership?.consentGeneration ?? 0); return { ...(ownership ? { ownership } : {}), @@ -448,9 +439,25 @@ export interface ServiceStateSwapDeps { const SERVICE_STATE_SWAP_ATTEMPTS = 5; const SERVICE_STATE_LOCK_WAIT_MS = 2_000; const SERVICE_STATE_LOCK_POLL_MS = 20; -const SERVICE_STATE_LOCK_STALE_MS = 30_000; -/** Lock paths this process holds, with a depth so a nested swap does not deadlock on itself. */ -const heldStateLocks = new Map(); +/** + * How old a lock must be before it is treated as abandoned. + * + * It has to exceed the longest legitimate critical section, not the typical one. On Windows + * each committed path runs `hardenSecretPath` synchronously, whose own documentation records + * a worst case around ninety seconds for sequential calls under load; a thirty-second + * threshold would let a second writer evict a holder that is simply still working, and both + * would then compute the same base revision and write over each other. + */ +const SERVICE_STATE_LOCK_STALE_MS = 300_000; +const SERVICE_STATE_REPLACE_ATTEMPTS = 5; +const SERVICE_STATE_REPLACE_RETRY_MS = 40; +/** Lock paths this process holds, with the token written into each and a re-entrancy depth. */ +const heldStateLocks = new Map(); + +/** The token inside a lock file, or null when it cannot be read. */ +function readLockToken(lockPath: string): string | null { + try { return readFileSync(lockPath, "utf8").trim() || null; } catch { return null; } +} function isFileExistsError(error: unknown): boolean { return Boolean(error) && typeof error === "object" && "code" in (error as object) @@ -473,14 +480,15 @@ function isFileExistsError(error: unknown): boolean { */ function withServiceStateLock(anchor: string, run: () => T, waitMs = SERVICE_STATE_LOCK_WAIT_MS): T { const lockPath = `${anchor}.lock`; - const depth = heldStateLocks.get(lockPath); - if (depth !== undefined) { - heldStateLocks.set(lockPath, depth + 1); - try { return run(); } finally { releaseHeldLock(lockPath, false); } + const held = heldStateLocks.get(lockPath); + if (held !== undefined) { + held.depth += 1; + try { return run(); } finally { releaseHeldLock(lockPath); } } const dir = dirname(lockPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); const deadline = Date.now() + waitMs; + const token = randomUUID(); let fd: number; for (;;) { try { @@ -493,10 +501,17 @@ function withServiceStateLock(anchor: string, run: () => T, waitMs = SERVICE_ if (Date.now() < deadline) { Bun.sleepSync(SERVICE_STATE_LOCK_POLL_MS); continue; } // Break a lock whose holder is gone. Age comes from the lock file itself, so a holder // that is merely slow keeps refusing us rather than being evicted mid-write. + // + // The token is re-read and compared before the unlink: without it, a holder that + // released and a NEW holder that took the lock in the same instant would be evicted as + // if it were the abandoned one, and two writers would proceed from one base revision. + const abandoned = readLockToken(lockPath); let ageMs: number | null = null; try { ageMs = Date.now() - statSync(lockPath).mtimeMs; } catch { ageMs = null; } if (ageMs !== null && ageMs > SERVICE_STATE_LOCK_STALE_MS) { - try { unlinkSync(lockPath); } catch { /* another process broke it first */ } + if (readLockToken(lockPath) === abandoned) { + try { unlinkSync(lockPath); } catch { /* another process broke it first */ } + } continue; } throw new Error( @@ -505,20 +520,29 @@ function withServiceStateLock(anchor: string, run: () => T, waitMs = SERVICE_ ); } } - heldStateLocks.set(lockPath, 1); + // Identify the holder inside the file so neither eviction nor release can remove a lock + // some other process has since taken. + try { writeFileSync(fd, `${token}\n`, { encoding: "utf8" }); } catch { /* best-effort */ } + heldStateLocks.set(lockPath, { depth: 1, token }); try { return run(); } finally { try { closeSync(fd); } catch { /* best-effort */ } - releaseHeldLock(lockPath, true); + releaseHeldLock(lockPath); } } -function releaseHeldLock(lockPath: string, removeFile: boolean): void { - const depth = (heldStateLocks.get(lockPath) ?? 1) - 1; - if (depth > 0) { heldStateLocks.set(lockPath, depth); return; } +function releaseHeldLock(lockPath: string): void { + const held = heldStateLocks.get(lockPath); + if (held === undefined) return; + held.depth -= 1; + if (held.depth > 0) return; heldStateLocks.delete(lockPath); - if (removeFile) { try { unlinkSync(lockPath); } catch { /* best-effort */ } } + // Remove OUR lock instance only. If the file on disk carries a different token, this + // holder was evicted as stale and someone else owns the pathname now; unlinking it would + // hand a third writer the lock while the second is still inside its critical section. + if (readLockToken(lockPath) !== held.token) return; + try { unlinkSync(lockPath); } catch { /* best-effort */ } } /** One state path's record, or null when it is absent or unparseable. Throws if unreadable. */ @@ -536,13 +560,51 @@ function readServiceInstallStateAt(path: string): ServiceInstallState | null { return evidence.kind === "valid" ? evidence.state : null; } +/** + * Publish one state file, replacing it as a unit. + * + * An in-place write truncates first, so a kill, a power loss or a failed write between the + * truncate and the last byte leaves the anchor empty or half-serialized. That used to read + * back as "no install state"; since the reader became fail-closed it reads as `unknown`, + * which blocks `service start`, repair, restart and every update until the operator runs a + * takeover install. Writing a sibling temporary file and renaming it means the previous valid + * record survives an interrupted commit. + * + * The temporary file is hardened BEFORE the rename, not after: between rename and chmod the + * record would otherwise be readable at the default mode. + * + * Windows can refuse the replace while a scanner or another reader holds the destination + * open. That is transient, so it is retried briefly and then falls back to the in-place + * write — a narrow torn-write window is a better failure than an install that cannot record + * what it just registered. + */ function commitServiceStateFile(path: string, serialized: string): void { const dir = dirname(path); recordOwnedConfigPath(getConfigDir(), path); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(path, serialized, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: true }); + const staged = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`; + try { + writeFileSync(staged, serialized, { encoding: "utf8", mode: 0o600 }); + try { chmodSync(staged, 0o600); } catch { /* best-effort */ } + if (process.platform === "win32") hardenSecretPath(staged, { required: true }); + for (let attempt = 0; ; attempt += 1) { + try { + renameSync(staged, path); + return; + } catch { + if (attempt >= SERVICE_STATE_REPLACE_ATTEMPTS - 1) { + writeFileSync(path, serialized, { encoding: "utf8", mode: 0o600 }); + try { chmodSync(path, 0o600); } catch { /* best-effort */ } + if (process.platform === "win32") hardenSecretPath(path, { required: true }); + return; + } + Bun.sleepSync(SERVICE_STATE_REPLACE_RETRY_MS); + } + } + } finally { + // A rename that succeeded consumed the staged path; anything left is ours to clean up. + if (existsSync(staged)) { try { unlinkSync(staged); } catch { /* best-effort */ } } + } } /** @@ -618,31 +680,10 @@ export type ServiceOwnershipResolution = export function resolveServiceOwnership( evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(), ): ServiceOwnershipResolution { - for (const entry of evidence) { - // Any path. A claim we are not allowed to look at is still a claim. - if (entry.kind === "unreadable") { - return { kind: "unknown", reason: `a service state path could not be read (${entry.reason})` }; - } - } - // Only the ANCHOR's corruption is fatal. The second path is the legacy default-home entry - // kept so an install made before OPENCODEX_HOME existed can still be found; unrelated junk - // left there by an old version must not be able to block every repair on this machine. - if (evidence[0]?.kind === "invalid") { - return { kind: "unknown", reason: "the service install record is present but not valid" }; - } - const claims = evidence.flatMap(entry => ( - entry.kind === "valid" && entry.state.ownership ? [entry.state.ownership] : [] - )); - const first = claims[0]; - if (first === undefined) return { kind: "none" }; - if (claims.some(claim => claim.owner !== first.owner || claim.installId !== first.installId)) { - return { kind: "unknown", reason: "the service state paths name different owners" }; - } - // Same claim in both places; the higher generation is the later write. - return { - kind: "owned", - ownership: claims.reduce((best, claim) => claim.consentGeneration > best.consentGeneration ? claim : best, first), - }; + // The resolution rule is the shared contract's, for the same reason the record contract is: + // the Node launcher decides this question too, and a weaker copy there is an authorization + // gap rather than a style problem. + return resolveOwnershipFromEvidence(evidence) as unknown as ServiceOwnershipResolution; } /** @@ -774,28 +815,12 @@ export type ServiceStateEvidence = export function inspectServiceStateEvidence( paths: readonly string[] = serviceStatePaths(), ): readonly ServiceStateEvidence[] { - return paths.map((path): ServiceStateEvidence => { - let raw: string; - try { - raw = readFileSync(path, "utf8"); - } catch (error) { - const code = error && typeof error === "object" && "code" in error - ? String((error as { code?: unknown }).code) - : ""; - // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask, - // and collapsing them into absence is how a locked-down state file would - // become permission to write. - if (code === "ENOENT") return { path, kind: "absent" }; - return { path, kind: "unreadable", reason: code || String(error) }; - } - let parsed: ServiceInstallState | null; - try { - parsed = parseServiceInstallState(JSON.parse(raw)); - } catch { - return { path, kind: "invalid" }; - } - return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" }; - }); + // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask, and collapsing + // them into absence is how a locked-down state file would become permission to write. + // The classification is the shared contract's, so the launcher makes the same call. + return paths.map(path => ( + inspectInstallStateBytes(path, at => readFileSync(at, "utf8")) as unknown as ServiceStateEvidence + )); } /** The homes this process is actually using, for comparison against a claim. */ diff --git a/src/update/index.ts b/src/update/index.ts index f8286e0afaa..17ffa6dadaf 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -404,7 +404,7 @@ export async function runUpdate(): Promise { } catch { /* best-effort */ } // What this update may do to the runtime. A desktop takeover vetoes both the stop and the // service refresh below; see `planUpdateRuntimeHandling` for why each half is wrong. - const runtimePlan = planUpdateRuntimeHandling({ + let runtimePlan = planUpdateRuntimeHandling({ ...(await resolvedRuntimeOwnership()), serviceInstalled: serviceWasInstalled, }); @@ -456,6 +456,19 @@ export async function runUpdate(): Promise { // silently skips the recovery the receipt was written to trigger (#3008). // Full `ocx stop` semantics (drain, service stop, restore). let stopAttempted = false; + // Re-read at the point of action rather than trusting the plan formed above. Between the + // two the Windows tray handoff spawns children and the listen target is captured, so a + // takeover can land in between — and stopping a runtime that just changed hands is the + // failure this lane exists to prevent. Reassigning the one variable keeps the recovery + // branches and the restart hint reading the same decision as the stop. + { + const atStop = planUpdateRuntimeHandling({ + ...(await resolvedRuntimeOwnership()), + serviceInstalled: serviceWasInstalled, + }); + if (atStop.notice && atStop.notice !== runtimePlan.notice) console.log(atStop.notice); + runtimePlan = atStop; + } if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); diff --git a/src/update/runtime-ownership.d.mts b/src/update/runtime-ownership.d.mts index 02cd87fad08..cc9b74fd853 100644 --- a/src/update/runtime-ownership.d.mts +++ b/src/update/runtime-ownership.d.mts @@ -1,10 +1,4 @@ /** Declaration for the plain-ESM runtime-ownership rule shared with `bin/ocx.mjs`. */ -export declare function parseRecordedOwnership(raw: string | null | undefined): { - owner: string; - installId: string; - consentGeneration: number; -} | null; - export declare function planUpdateRuntimeHandling(input: { ownership: { owner: string; installId: string; consentGeneration: number } | null; ownershipUnknown?: boolean; diff --git a/src/update/runtime-ownership.mjs b/src/update/runtime-ownership.mjs index a48339de972..0e7fba1341f 100644 --- a/src/update/runtime-ownership.mjs +++ b/src/update/runtime-ownership.mjs @@ -7,35 +7,17 @@ * situation separately is how a fix ships on one side only. */ -/** - * The ownership claim recorded in `service-state.json`, or null. - * - * Kept in step with `parseServiceOwnership` in `src/service/state.ts`, which is the - * authoritative reader; this one exists because the Node launcher cannot import TypeScript. - * `tests/update/update-desktop-owner.test.ts` drives the same accepted and rejected shapes - * through both so they cannot drift apart. +/* + * There is deliberately no ownership PARSER here any more. * - * @param {string | null | undefined} raw The state file's text, or null when it is absent. - * @returns {{ owner: string, installId: string, consentGeneration: number } | null} + * This module used to carry one so the Node launcher could read the record without importing + * TypeScript, kept "in step" with the authoritative reader by a test that drove the same + * shapes through both. It was not in step: it inspected one path and treated a record that + * failed the whole install-state contract as an unowned runtime whenever its `ownership` + * field was simply absent. Reading and resolving now live in + * `src/service/install-state-contract.mjs`, which both runtimes import, so there is one + * algorithm rather than two that a test has to keep aligned. */ -export function parseRecordedOwnership(raw) { - if (typeof raw !== "string" || raw.length === 0) return null; - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - const ownership = parsed.ownership; - if (!ownership || typeof ownership !== "object" || Array.isArray(ownership)) return null; - if (ownership.owner !== "cli" && ownership.owner !== "desktop") return null; - if (typeof ownership.installId !== "string" || ownership.installId.length === 0) return null; - const generation = ownership.consentGeneration; - if (typeof generation !== "number" || !Number.isInteger(generation) || generation < 0) return null; - return ownership; -} - /** * Decide how an update treats a runtime it may not own. * diff --git a/structure/runtime.md b/structure/runtime.md index 64172878654..438e95547c6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -530,14 +530,31 @@ opaque `installId` naming the owning installation rather than the user or the ma `consentGeneration`. An absent claim means the CLI install that registered the service owns the runtime, which is what every record written before the field existed says. +`src/service/install-state-contract.mjs` holds the record shape, the path list and the +resolution rule, and both runtimes import it: `src/service/state.ts` and the Node launcher +`bin/ocx.mjs`, which cannot import TypeScript. The launcher previously kept its own reader, +and the divergence was an authorization gap rather than a style problem — it inspected only +the anchor path and answered "unowned" for any record whose `ownership` field was absent, +including one that failed the contract outright. + Every write goes through `swapServiceInstallState`. It holds an `O_EXCL` lock beside the anchor record for the whole read-modify-write, re-reads the anchor immediately before committing and compares the committed bytes afterwards, and it runs the whole sequence again when another writer landed inside that window; `revision` is the compare-and-swap token. The lock excludes cooperating writers, and the revision check catches a writer that does not take -it, such as an older `ocx` on the same machine. `writeServiceInstallState` rebuilds only the -install provenance and carries the ownership claim across unchanged, which is what keeps an -install, a repair, an update or a stop from dropping it. +it, such as an older `ocx` on the same machine. The lock file carries a token identifying its +holder, so eviction and release each remove only the instance they own, and the stale +threshold exceeds the longest legitimate critical section rather than the typical one. Each +file is published by writing a sibling temporary file and renaming it, so an interrupted +commit leaves the previous valid record rather than a truncated one the fail-closed reader +would report as unknown. + +`writeServiceInstallState` rebuilds only the install provenance and carries the ownership +claim across unchanged, which is what keeps an install, a repair, an update or a stop from +dropping it. It resolves that claim INSIDE the swap, while the lock is held: a resolution +taken beforehand is a lost update the compare-and-swap cannot detect, because the stale value +never came from the base record. Where the anchor and the cross-path resolution still +disagree, the higher `consentGeneration` wins and an equal generation keeps the anchor. `resolveServiceOwnership` is how a claim is read for a decision. It reads every state path and answers `none`, `owned` or `unknown`; absence is the only thing that means no claim, so @@ -558,11 +575,19 @@ The verbs that ACTIVATE the npm registration refuse on a foreign or unknown owne they deactivate. `src/update/runtime-ownership.mjs` vetoes both the pre-update stop and the post-update service refresh for all three update lanes — `src/update/index.ts`, `bin/ocx.mjs` and the dashboard worker in `src/update/job.ts` — and the two package updaters -re-read the claim before any direct-start fallback, because an app can take the runtime during -an install that takes minutes. The registration is never deleted; `ocx service install` is the -one verb that releases the marker, and it does so only after the registration succeeded. +re-read the claim immediately before each runtime action — the stop and the direct-start +fallback — rather than trusting a plan formed earlier in the run, because an app can take the +runtime while the tray handoff spawns children or an install runs for minutes. The +registration is never deleted; `ocx service install` is the one verb that releases the +marker, and it does so only after the registration succeeded. The veto reads the recorded claim, not the live process. An app removed without releasing leaves a stale claim, and proving which runtime is answering needs the identity the bundled CLI's resolve contract will carry; until then the refusals name `ocx service install` as the way to clear it. + +Re-reading narrows the window between a decision and its action; it does not remove it. A +claim recorded after the last read and before the child process starts is still acted on with +stale information. Closing that needs an action-scoped ownership lease held across the child, +which the state lock deliberately is not — holding it across `ocx stop` or a service refresh +would deadlock against the child's own write. diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index cf167a54fba..3de8dd9b90e 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -8,7 +8,7 @@ * doctor suggestion — and nothing said it had gone. */ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { createTempHome, type TempHome } from "../helpers/temp-home"; import { repoPath } from "../helpers/repo-root"; import { @@ -331,4 +331,84 @@ describe("the anchor lock", () => { }); expect(result?.ownership?.installId).toBe("app-install-a"); }); + + /** + * The lock file names its holder. Without that, a holder evicted as stale would delete the + * REPLACEMENT lock on its way out and hand a third writer the pathname while the second is + * still inside its critical section. + */ + test("release removes only the lock instance this holder created", () => { + const lockPath = serviceStatePath() + ".lock"; + let observed = ""; + writeServiceInstallState("scheduler", null); + swapServiceInstallState(current => { + observed = readFileSync(lockPath, "utf8").trim(); + // Stand in for an eviction: the pathname now belongs to somebody else. + writeFileSync(lockPath, "a-different-holder\n"); + return { ...current! }; + }); + expect(observed).not.toBe(""); + expect(existsSync(lockPath)).toBe(true); + expect(readFileSync(lockPath, "utf8").trim()).toBe("a-different-holder"); + unlinkSync(lockPath); + }); +}); + +describe("the record is replaced as a unit", () => { + /** + * An in-place write truncates first, so an interrupted commit used to leave the anchor empty + * or half-serialized. Since the reader became fail-closed that reads as `unknown`, which + * blocks start, repair, restart and every update until the operator runs a takeover install. + */ + test("a commit leaves no staging file behind and the record stays parseable", () => { + writeServiceInstallState("scheduler", null); + recordServiceOwner(DESKTOP); + const leftovers = readdirSync(home.root).filter(name => name.endsWith(".tmp")); + expect(leftovers).toEqual([]); + expect(readServiceInstallState()?.ownership?.installId).toBe("app-install-a"); + }); + + test("the write path stages and renames rather than truncating the record in place", () => { + const source = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); + const commit = source.slice( + source.indexOf("function commitServiceStateFile("), + source.indexOf("export function swapServiceInstallState("), + ); + expect(commit).toContain("renameSync(staged, path)"); + // Hardened BEFORE the rename: between rename and chmod the record would be readable + // at the default mode. + expect(commit.indexOf("hardenSecretPath(staged")).toBeLessThan(commit.indexOf("renameSync(staged, path)")); + }); +}); + +describe("a claim recorded under the lock is never overwritten by an older one", () => { + /** + * `writeServiceInstallState` used to resolve ownership BEFORE the swap took the lock. A + * takeover landing in between reached `current`, passed the revision check untouched, and + * was then overwritten by the older claim the resolution had captured — a lost update the + * compare-and-swap cannot see, because the stale value never came from the base record. + */ + test("the resolution is read inside the swap, not before it", () => { + const source = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); + const writer = source.slice( + source.indexOf("export function writeServiceInstallState("), + source.indexOf("function preservedConsent("), + ); + expect(writer).toContain("preservedConsent(current, resolveServiceOwnership())"); + expect(writer).not.toMatch(/const resolution = resolveServiceOwnership\(\);/); + }); + + test("a higher generation wins, and an equal generation keeps the anchor", () => { + recordServiceOwner(DESKTOP); + recordServiceOwner({ owner: "desktop", installId: "app-install-b" }); + const before = readServiceInstallState(); + expect(before?.ownership?.installId).toBe("app-install-b"); + expect(before?.ownership?.consentGeneration).toBe(2); + + // An ordinary install-state refresh must not demote it to the earlier grant. + writeServiceInstallState("scheduler", null); + const after = readServiceInstallState(); + expect(after?.ownership?.installId).toBe("app-install-b"); + expect(after?.ownership?.consentGeneration).toBe(2); + }); }); diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index 3300a3d051f..d9683958760 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -11,7 +11,12 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { repoPath } from "../helpers/repo-root"; -import { parseRecordedOwnership, planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; +import { planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; +import { + inspectInstallStateBytes, + resolveOwnershipFromEvidence, + serviceStateFilesFor, +} from "../../src/service/install-state-contract.mjs"; import { parseServiceOwnership } from "../../src/service/state"; describe("the runtime-ownership veto", () => { @@ -45,7 +50,14 @@ describe("the runtime-ownership veto", () => { }); }); -describe("the launcher's reader agrees with the authoritative one", () => { +/** + * The launcher used to keep its own reader, "kept in step" by a table of claim shapes. It was + * not in step: it inspected only the anchor path, and it treated a record that fails the whole + * install-state contract as an unowned runtime whenever its `ownership` field was simply + * absent. That is permission to stop a foreign runtime and reactivate the npm service, so the + * reader is gone and both runtimes import one contract. + */ +describe("one contract, not two readers kept in step", () => { const accepted = [ { owner: "desktop", installId: "a", consentGeneration: 0 }, { owner: "cli", installId: "a", consentGeneration: 12 }, @@ -61,27 +73,79 @@ describe("the launcher's reader agrees with the authoritative one", () => { "desktop", null, ]; + const record = (extra: Record): string => JSON.stringify({ + version: 2, codexHome: "/c", opencodexHome: "/o", backend: "scheduler", ...extra, + }); + const resolveText = (text: string) => resolveOwnershipFromEvidence( + [inspectInstallStateBytes("/anchor", () => text)], + ); - test("both accept the same claims", () => { + test("the authoritative reader and the shared contract accept the same claims", () => { for (const ownership of accepted) { expect(parseServiceOwnership(ownership)).not.toBeNull(); - expect(parseRecordedOwnership(JSON.stringify({ ownership }))).toEqual(ownership); + expect(resolveText(record({ ownership }))).toEqual({ kind: "owned", ownership }); } }); - test("both reject the same claims", () => { + test("a rejected claim is unknown, never an unowned runtime", () => { for (const ownership of rejected) { expect(parseServiceOwnership(ownership)).toBeNull(); - expect(parseRecordedOwnership(JSON.stringify({ ownership }))).toBeNull(); + expect(resolveText(record({ ownership })).kind).toBe("unknown"); } }); - test("an absent, empty or unparseable record is not a claim", () => { - expect(parseRecordedOwnership(null)).toBeNull(); - expect(parseRecordedOwnership("")).toBeNull(); - expect(parseRecordedOwnership("{")).toBeNull(); - expect(parseRecordedOwnership("[]")).toBeNull(); - expect(parseRecordedOwnership(JSON.stringify({ version: 2 }))).toBeNull(); + /** + * The case the launcher got wrong: a record that carries no `ownership` field but fails the + * contract for another reason. It answered "known unowned" and permitted the stop and the + * service refresh; the contract answers `unknown` and vetoes both. + */ + test("an ownership-free record that fails the contract is unknown, not unowned", () => { + expect(resolveText(JSON.stringify({ version: 2 })).kind).toBe("unknown"); + expect(resolveText(JSON.stringify({ version: 99, codexHome: "/c", opencodexHome: "/o" })).kind).toBe("unknown"); + expect(resolveText(record({ codexHome: "" })).kind).toBe("unknown"); + expect(resolveText("{").kind).toBe("unknown"); + expect(resolveText("[]").kind).toBe("unknown"); + // A record that satisfies the contract and simply has no claim is the one "none" case. + expect(resolveText(record({})).kind).toBe("none"); + }); + + test("a claim on the legacy path alone is still a claim, and a conflict is unknown", () => { + const anchor = inspectInstallStateBytes("/anchor", () => record({})); + const claim = { owner: "desktop", installId: "app-a", consentGeneration: 1 }; + const legacy = inspectInstallStateBytes("/legacy", () => record({ ownership: claim })); + expect(resolveOwnershipFromEvidence([anchor, legacy])).toEqual({ kind: "owned", ownership: claim }); + + const other = inspectInstallStateBytes("/legacy", () => record({ + ownership: { owner: "desktop", installId: "app-b", consentGeneration: 1 }, + })); + const claimed = inspectInstallStateBytes("/anchor", () => record({ ownership: claim })); + expect(resolveOwnershipFromEvidence([claimed, other]).kind).toBe("unknown"); + }); + + test("an unreadable path anywhere in the list is unknown", () => { + const unreadable = inspectInstallStateBytes("/legacy", () => { + const error = new Error("denied") as Error & { code?: string }; + error.code = "EACCES"; + throw error; + }); + expect(unreadable.kind).toBe("unreadable"); + const anchor = inspectInstallStateBytes("/anchor", () => record({})); + expect(resolveOwnershipFromEvidence([anchor, unreadable]).kind).toBe("unknown"); + }); + + test("an absent path is the only answer that can mean no claim", () => { + const absent = inspectInstallStateBytes("/anchor", () => { + const error = new Error("missing") as Error & { code?: string }; + error.code = "ENOENT"; + throw error; + }); + expect(absent.kind).toBe("absent"); + expect(resolveOwnershipFromEvidence([absent])).toEqual({ kind: "none" }); + }); + + test("both runtimes consult the same path list", () => { + expect(serviceStateFilesFor("/home/.opencodex", "/home/.opencodex")).toHaveLength(1); + expect(serviceStateFilesFor("/pinned", "/home/.opencodex")).toHaveLength(2); }); }); @@ -112,6 +176,24 @@ describe("both updaters consult the shared rule", () => { expect(source).not.toMatch(/owner\s*!==\s*"cli"/); } }); + + /** + * The launcher's own reader is what made the two lanes disagree, so its absence is the + * property worth pinning: no JSON.parse of the state record, no claim validation, and the + * contract module imported instead. + */ + test("the launcher reads the record only through the shared contract", () => { + expect(launcher).toContain('from "../src/service/install-state-contract.mjs"'); + expect(launcher).toContain("resolveOwnershipFromEvidence(evidence)"); + expect(launcher).toContain("serviceStateFilesFor("); + expect(launcher).not.toContain("parsed.ownership"); + expect(launcher).not.toContain("consentGeneration"); + // The authoritative reader delegates to the same module rather than keeping a twin. + const state = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); + expect(state).toContain('from "./install-state-contract.mjs"'); + expect(state).toContain("return parseInstallStateRecord(value)"); + expect(state).toContain("return resolveOwnershipFromEvidence(evidence)"); + }); }); describe("an unreadable record is not an unowned runtime", () => { diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index c556bd34249..010bab6cc63 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -602,9 +602,12 @@ describe("update stops the running proxy before replacing files", () => { expect(updateSource).toContain("serviceReinstallArgs()"); expect(launcherSource).toContain("aborting the update"); expect(launcherSource).toContain('"service", "repair"'); - // The launcher still reads service-state.json for service-installed detection, and - // for the backend choice on the genuinely-absent install fallback. - expect(launcherSource).toContain('"service-state.json"'); + // The launcher still reads the install-state record for service-installed detection, and + // for the backend choice on the genuinely-absent install fallback. It no longer spells the + // file name: the path list moved into the contract both runtimes import, so the launcher + // cannot consult a shorter list than the authoritative reader. + expect(launcherSource).toContain("serviceStateFilesFor(configDir()"); + expect(launcherSource).toContain("const serviceWasInstalled = existsSync(serviceStatePath)"); // That marker can be STALE, so the fallback asks for structured state rather than // parsing a failure message; bin/ocx.mjs is plain Node and cannot import // diagnoseService(), so it reads startup.serviceInstalled from `status --json`. From 19f0c89552179b2bcb9618521938575c48316d15 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 13:32:35 +0900 Subject: [PATCH 72/87] fix(gui): replace the platform dialogs the desktop webview cannot draw (#5387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gui): replace the platform dialogs the app webview cannot draw wry implements no WKUIDelegate JavaScript panel methods, so inside the desktop app confirm() returned false without drawing anything, alert() drew nothing and prompt() could not collect input. Thirteen consent gates and seven result reports across the dashboard were therefore inoperative there, including the sidebar stop and refresh orbs: the user clicked, was silently declined, and saw nothing. Replace all three with in-page surfaces already in the tree. confirmAction and requestTextValue open a real built the way admin-token-dialog.ts already builds one, keeping the call-site shape so a refusal still issues no request, and carrying the dismissal, focus-return and validation behaviour of the existing modals. The alert() reports move to ToastNotice, which useCodexRestart now requires its consumer to supply so a forgotten report fails to compile rather than vanishing. requestTextValue mirrors the alias contract both credential routes enforce, so an over-long or pasted control character is reported beside the field instead of returning an opaque 400. ProxyStopOutcome and CodexRestartOutcome become discriminated unions so a failure always carries its reason. Models.tsx sits on its recorded line cap, so alias editing moves to a sibling module rather than growing it. * test(gui): guard the dashboard against the platform dialogs by call form Matches the global CALL FORM rather than the identifier, because the identifier is legitimate in four places this repository uses: a confirm() method on a session object, the promptForAdminToken helper, a confirm prop, and an executable sample string containing the word. A lexical ban would reject all four. The scanner masks comments, string literals, template text and regular expressions before matching, keeping template holes visible because interpolated code is executable. Masking regular expressions is not cosmetic: an unmasked /[quote]/ reads as the start of a string literal and swallows the rest of the file, which would turn the guard green by blinding it. Driven red twice. Against origin/dev it reports all 25 pre-existing call sites with exact file:line, and clears the four legitimate shapes. Against a reintroduced window.confirm and alert in gui/src/lib/desktop-shell.ts it reports both and the tree test fails. * test(gui): assert the absence of platform dialogs instead of stubbing them in The GUI tests were the reason CI never saw this defect. codex-stale-banner-dom stubbed confirm() to true and alert() to a no-op, memory-observability-card forced confirmation, app-stop asserted that alert(outcome.message) existed, and four more files answered consent the same way. Each is reasonable alone; together they encoded browser dialogs as available and made a dashboard that could draw none of them look correct. Every one of those files now installs a throwing trap for confirm, alert and prompt, and answers the real in-page dialog through gui/tests/helpers/action-dialog.ts. Reaching a platform dialog fails the test. New coverage in gui/tests/action-dialogs.test.ts drives the helpers directly: a refusal resolves false and mounts nothing, Escape and the backdrop are refusals rather than unanswered closes, focus returns to the trigger, a destructive action does not put the accepting button under Enter, and a rejected value keeps the dialog open with an accessible report and no request. Three fixes the review found. The focus capture was duck-typed because instanceof HTMLElement bound every caller to a realm exposing that global, which most of this package's DOM tests do not. Teardown resolves in a finally block, since a dialog left connected is cosmetic but a promise that never settles hangs the handler awaiting it. ProviderModels claims its single flight before awaiting consent: the old gate was synchronous and nothing could interleave with it, and a browser's modal inertness is the platform's courtesy rather than this component's invariant. The scanner lost several blind spots, each a false negative. It now tracks the previous token rather than the previous character, so a JSX closing tag is not read as a regular expression and an apostrophe in JSX body text is not read as a string; both masked the rest of their line. It also reads confirm?.(x) and (confirm)(x). * docs(pr-assets): add the in-page consent and text-entry dialogs screenshot The two surfaces that replaced the platform dialogs the app webview cannot draw: the consent gate behind the sidebar stop orb, and the text entry that replaced window.prompt() for alias editing. Captured against a freshly started proxy with an empty config home, so it carries no account data. * fix(gui): dismiss an open dialog when its page navigates away, and close the guard's blind spots Review found two real defects in the dialog helper. A dialog is mounted on , so it outlives the React subtree that opened it: navigating with Back or Forward left it on screen, and accepting it afterwards resumed a closed-over handler against a page the user had already left, which for account removal or device revocation means a destructive action running from a surface they cannot see. Navigation is now a refusal, using the same two events the shell already treats as leaving a page, and both listeners come off when the dialog settles. Escape moves to the document. With showModal the dialog reports its own cancel event and this never fires; without it the element is merely open, so a dialog-level listener missed Escape as soon as focus sat anywhere else. That fallback path is not a modality boundary and the comment now says so plainly rather than implying a focus trap it does not have: every browser surface the dashboard ships to implements showModal and takes the other branch. A static review then demonstrated several calls the guard did not report, each a real hole rather than a stylistic gap: window.confirm?.(x), (window.confirm)(x), ((confirm))(x), a call at a statement start after automatic semicolon insertion, export default prompt(x), a postfix ++ making the next slash a division rather than a regular expression, and computed access through globalThis["alert"]. Computed access is matched against the raw source because the quotes that make it work are exactly what the mask removes. One regression was vacuous: the provider-model dismissal cases answered through the shared click helper, which returns silently when no dialog opens, so they would have passed if Delete and Hide became no-ops — which is the very defect this lane exists for. They now drive the dialog themselves and assert it appeared. The dialog test file also stops installing the HTMLElement global, so it proves the focus check stays realm-independent instead of hiding a reintroduction. * fix(gui): correct two unrun assertions that hosted CI caught Both were written under the no-local-execution contract and both were wrong about the environment rather than about the behaviour they describe. The document-Escape case asserted that this DOM has no showModal. It does, so the dialog always took the modal branch, the document listener was never registered, and the test sat until its deadline. It now deletes showModal from the prototype for the duration, which is the only way to reach the fallback at all, and restores it afterwards. The parenthesized-callee expectation still named the older match shape. Widening the pattern to accept any number of wrapping parentheses moved the match start to the identifier, so the reported form is confirm)( rather than (confirm)(. Evidence: run 35547774047 at 683e929 reported 2261 pass / 1 fail in the GUI suite and one failing guard case in shard 1/4, which is exactly these two. * fix(gui): call the consent gate through a named async handler, not a floating .then React Doctor blocks on warnings and flagged two: no-floating-then-in-jsx-handler at ProviderSettings.tsx:422 and startup-sections.tsx:201. Both were promise chains I put directly in a JSX handler when converting these two sites away from the synchronous confirm(). A floating .then in a handler has no rejection path, so a throw inside the continuation becomes an unhandled rejection rather than anything the component can see. Each becomes a named async function the handler calls with void, which is how the rest of this codebase drives async work from an event. Behaviour is unchanged: the account-mode select still restores its visible value on refusal, reading currentTarget before the await because it is null once the handler resumes, and the tray uninstall still runs only on acceptance. The finding was not reachable from the PR: this workflow keeps the action's comment and inline outputs off for least privilege, so GitHub shows only the exit code. The rules and lines came from the run's job summary. * fix(gui): separate accepted, rejected and unknown, and bind consent to its subject Two findings from the external re-audit, both of the same shape: something uncertain was being recorded as something known. The stop client mapped every transport failure to accepted. A timeout, a connection that never arrived, a user abort and a connection dropped after the server accepted are different events, and collapsing them turned the fact that the user pressed the button into evidence that the server acted. ProxyStopOutcome now answers accepted, rejected or unknown. An unknown is settled by READING the instance again rather than by re-sending: a stop that did arrive would otherwise be repeated against whatever now holds the port. A refused follow-up connection is the evidence that the runtime went away, so that case reports accepted; a proxy that still answers reports unknown with that reason; anything else stays unknown. Disconnecting a client ends no process, so there is nothing to re-read and it stays unknown. A 2xx whose body cannot be parsed is also unknown, because success: false rides in the body that was lost. App reports a refusal in the error tone and an unknown in the warn tone, because degraded is not failed. The restart consent could outlive its subject. The dialog is asynchronous, so the surface can unmount or the backend base can change while the question is still on screen, and the request then went to the closure's captured target. Consent now carries a cancellation lifetime: a changed base or an unmount withdraws the open dialog, and the confirmed path re-checks that the base it captured is still the current one before sending. confirmAction and requestTextValue accept an AbortSignal for this, and answer a refusal when it is already aborted rather than drawing a question nobody can act on. Also pins the template-literal distinction the re-audit doubted: a backtick literal's text is prose, and the dashboard ships an executable sample that spells out a prompt call on purpose, but a ${...} hole is executable code. The guard is supposed to report the second and ignore the first, so both are now regression cases. --- .../260921-lane-d-consent-surface.png | Bin 0 -> 39700 bytes gui/src/App.tsx | 60 +++- gui/src/action-dialogs.ts | 331 ++++++++++++++++++ gui/src/codex-restart.ts | 15 +- gui/src/components/CodexAccountPool.tsx | 11 +- .../components/MemoryObservabilityCard.tsx | 34 +- .../provider-workspace/ProviderModels.tsx | 16 +- .../provider-workspace/ProviderSettings.tsx | 28 +- gui/src/credential-alias.ts | 35 ++ gui/src/hooks/useProviderAccountPools.ts | 23 +- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/vi.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/Models.tsx | 73 ++-- gui/src/pages/RemoteWorkspace.tsx | 3 +- gui/src/pages/RoutingProfiles.tsx | 3 +- .../pages/integrations/ConsequenceDialog.tsx | 7 +- gui/src/pages/models-alias-editing.ts | 54 +++ gui/src/pages/startup-sections.tsx | 16 +- gui/src/stop-proxy.ts | 96 ++++- gui/src/use-codex-restart.ts | 83 ++++- gui/tests/action-dialogs.test.ts | 293 ++++++++++++++++ gui/tests/app-stop.test.ts | 95 ++++- .../codex-account-pool-toast-tone.test.tsx | 31 +- gui/tests/codex-restart.test.ts | 6 +- gui/tests/codex-stale-banner-dom.test.tsx | 104 ++++-- gui/tests/helpers/action-dialog.ts | 43 +++ gui/tests/memory-observability-card.test.tsx | 50 ++- gui/tests/provider-model-management.test.tsx | 57 ++- .../provider-settings-account-mode.test.tsx | 37 +- scripts/test-layout/layout.json | 3 +- tests/fixtures/test-layout-expected.json | 3 +- tests/gui/platform-dialog-guard.test.ts | 161 +++++++++ tests/helpers/platform-dialog-scan.ts | 278 +++++++++++++++ 41 files changed, 1880 insertions(+), 199 deletions(-) create mode 100644 .github/pr-assets/260921-lane-d-consent-surface.png create mode 100644 gui/src/action-dialogs.ts create mode 100644 gui/src/credential-alias.ts create mode 100644 gui/src/pages/models-alias-editing.ts create mode 100644 gui/tests/action-dialogs.test.ts create mode 100644 gui/tests/helpers/action-dialog.ts create mode 100644 tests/gui/platform-dialog-guard.test.ts create mode 100644 tests/helpers/platform-dialog-scan.ts diff --git a/.github/pr-assets/260921-lane-d-consent-surface.png b/.github/pr-assets/260921-lane-d-consent-surface.png new file mode 100644 index 0000000000000000000000000000000000000000..44a46139e4e3748c9146447493790614565a06d2 GIT binary patch literal 39700 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≧d82beCb=?l#<6wu+Lm`?kMj_&@mI4%{m9J?`H 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 literal 0 HcmV?d00001 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index c53975f682a..9a3c448074c 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useKeyedClientResource } from "./client-resource"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; @@ -15,7 +15,7 @@ import ErrorBoundary from "./components/ErrorBoundary"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; -import { Select } from "./ui"; +import { Select, ToastNotice, type NoticeTone } from "./ui"; import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; @@ -24,6 +24,7 @@ import { readModelsTab, type ModelsTab } from "./pages/models-tab"; import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; import { useCodexRestart } from "./use-codex-restart"; +import { confirmAction } from "./action-dialogs"; import { isDesktopShell, isExternalLink } from "./lib/desktop-shell"; type Theme = "light" | "dark" | "system"; @@ -117,6 +118,18 @@ export default function App() { const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); const [sharedSessionEpoch, setSharedSessionEpoch] = useState(0); const [sessionLoggingOut, setSessionLoggingOut] = useState(false); + /* + * Results from the two sidebar orbs used to be `alert()`, which the app's webview draws + * nowhere, so a refused stop and a completed one looked identical: nothing happened. + * The toast is portaled, so reporting from the shell costs the page no layout. + */ + const [actionFeedback, setActionFeedback] = useState<{ tone: NoticeTone; text: string } | null>(null); + /** Bumped on every report so a repeated identical message restarts the dismiss timer. */ + const [feedbackRevision, setFeedbackRevision] = useState(0); + const report = useCallback((text: string, tone: NoticeTone) => { + setActionFeedback({ tone, text }); + setFeedbackRevision(revision => revision + 1); + }, []); useEffect(() => { const unavailable = (event: Event) => { @@ -196,6 +209,14 @@ export default function App() { else { el.setAttribute("data-theme", theme); localStorage.setItem(THEME_KEY, theme); } }, [theme]); + // Success expires on its own; a failure and a degraded result stay until the user + // dismisses them, because those are the two the user has to act on. + useEffect(() => { + if (actionFeedback?.tone !== "ok") return; + const timer = window.setTimeout(() => setActionFeedback(null), 4500); + return () => window.clearTimeout(timer); + }, [actionFeedback, feedbackRevision]); + const healthPoll = useKeyedClientResource( `app-healthz:${machineBase}`, [machineBase, targetsSettled], @@ -248,21 +269,35 @@ export default function App() { const [codexRestartEpoch, setCodexRestartEpoch] = useState(0); const { restarting: codexRestarting, restart: handleCodexRestart } = useCodexRestart(sharedBase, { onSettled: () => setCodexRestartEpoch(epoch => epoch + 1), + report, }); const handleStop = async () => { - if (!confirm(t(targets.connected ? "connection.disconnectConfirm" : "dash.stopConfirm"))) return; + const consented = await confirmAction({ + message: t(targets.connected ? "connection.disconnectConfirm" : "dash.stopConfirm"), + confirmLabel: t(targets.connected ? "connection.disconnect" : "dash.stop"), + tone: "danger", + }); + if (!consented) return; setStopping(true); const outcome = await requestProxyStop(machineBase, { formatFailure: status => t("dash.stopFailed", { status: String(status) }), + formatStillRunning: () => t("dash.stopStillRunning"), + formatUnknown: () => t("dash.stopUnknown"), mode: targets.connected ? "client" : "standalone", }); - // Refusals and restore failures return normally instead of dropping the connection. - // In both cases the proxy did not reach a clean-stop result, so re-enable the control - // and surface the server's remediation instead of leaving "stopping…" stuck forever. - if (!outcome.accepted) { + /* + * Only an accepted stop leaves the control pending, because the page is about to go + * away with the server. A refusal and an unknown both mean the user is still here and + * still looking at a running dashboard, so the control comes back either way. + * + * They are not reported the same, though. A refusal is the server's own answer and + * reads as a failure; an unknown is the absence of an answer, and claiming either + * success or failure there is the thing this lane exists to stop. + */ + if (outcome.status !== "accepted") { setStopping(false); - alert(outcome.message); + report(outcome.message, outcome.status === "rejected" ? "err" : "warn"); } }; @@ -272,7 +307,7 @@ export default function App() { const loggedOut = await logoutApiSession("shared"); setSessionLoggingOut(false); if (loggedOut) setSharedSessionReady(false); - else alert(t("connection.sessionLogoutFailed")); + else report(t("connection.sessionLogoutFailed"), "err"); }; /* @@ -303,6 +338,11 @@ export default function App() { return (

+ {actionFeedback && ( + setActionFeedback(null)} dismissLabel={t("common.close")}> + {actionFeedback.text} + + )} {/* inert while the drawer is open: keeps focus and assistive tech inside the drawer */}
diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index 944e6a0cb5c..e8a10189520 100644 --- a/gui/src/components/provider-workspace/ProviderModels.tsx +++ b/gui/src/components/provider-workspace/ProviderModels.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { Switch } from "../../ui"; +import { confirmAction } from "../../action-dialogs"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { filterFreeModelRows, freeOnlyInForce, modelPricingKnown, type ModelRow } from "../../pages/models-shared"; import { putModelVisibility } from "../../model-visibility"; @@ -190,8 +191,21 @@ function ProviderModelInventory({ item, apiBase, availableModels, selectedModels const removeModel = async (row: ModelRow, button: HTMLButtonElement) => { const action = actionFor(row); if (actionsBlocked || flight.current || !action) return; - if (!window.confirm(t(action === "delete" ? "models.customDeleteConfirm" : "models.hideConfirm", { name: row.namespaced }))) return; + /* + * The single flight is claimed BEFORE consent is awaited. The gate used to be the + * synchronous `window.confirm()`, which nothing could interleave with; an in-page dialog + * yields, so without this a second row's button could open its own dialog while this one + * is still open and two removals would run against one revision. A browser's modal + * dialog makes the page inert, but that is the platform's courtesy, not this + * component's invariant. + */ flight.current = true; + const consented = await confirmAction({ + message: t(action === "delete" ? "models.customDeleteConfirm" : "models.hideConfirm", { name: row.namespaced }), + confirmLabel: t(action === "delete" ? "common.delete" : "common.ok"), + tone: "danger", + }); + if (!consented) { flight.current = false; return; } setRequestPending(true); setMutation(null); focusIntent.current = { button, retained: document.activeElement === button }; diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 847fe7e7122..ebfa5a20e02 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../../base-url-choice"; import { readJsonIfOk } from "../../fetch-json"; +import { confirmAction } from "../../action-dialogs"; import { createBoundedFetch } from "../../bounded-fetch"; import { startVisibilityPoll } from "../../visibility-poll"; import { useT } from "../../i18n/shared"; @@ -295,6 +296,23 @@ export default function ProviderSettings({ } }; + /** + * Asks before switching, because flipping modes rebinds running threads and changes quota + * accounting. Written as a named async function rather than a promise chain inside the + * handler: a floating `.then` in a JSX handler has no rejection path and is what + * `no-floating-then-in-jsx-handler` exists to catch. + */ + const requestAccountMode = async (next: "pool" | "direct", select: HTMLSelectElement) => { + if (await confirmAction({ message: t("pws.accountModeConfirm") })) { + await applyAccountMode(next); + return; + } + // Keep the visible choice aligned with the applied mode. React re-renders this + // controlled +
diff --git a/desktop/ui/main.js b/desktop/ui/main.js index 85c4ea87326..3f4516bda1a 100644 --- a/desktop/ui/main.js +++ b/desktop/ui/main.js @@ -1,34 +1,134 @@ -const params = new URLSearchParams(window.location.search); -const port = Number(params.get("port") || "10100"); -const origin = `http://127.0.0.1:${port}`; -const dashboardUrl = `${origin}/#/usage`; -const status = document.querySelector("#status"); +// The bootstrap page is the startup surface. It does not probe anything itself: the shell owns the +// sequence, its deadline and its diagnostic, and this page renders what it is told. The phase list +// is asked for rather than written here, so a state added in the shell appears without a second +// edit — and one removed cannot leave a row behind. +// +// What each row shows comes from the shell too, including the states already finished. Rebuilding +// that history from events would be wrong: the first states finish in milliseconds, so a page whose +// listener attached a moment late would show a run in progress with nothing behind it. +// +// Nothing here uses alert, confirm or prompt. The embedded webview implements none of the +// WKUIDelegate panel methods on macOS, so a platform dialog is silently declined and the user sees +// nothing at all. Every message this page has goes into the page — including its own failures, +// because a surface that cannot report is the problem this file exists to fix. + +const bridge = window.__TAURI__; +const invoke = bridge && bridge.core && bridge.core.invoke; +const listen = bridge && bridge.event && bridge.event.listen; + +const headline = document.querySelector("#headline"); +const detail = document.querySelector("#detail"); +const phaseList = document.querySelector("#phases"); +const failure = document.querySelector("#failure"); const retry = document.querySelector("#retry"); -let checking = false; +const copy = document.querySelector("#copy"); +const copyState = document.querySelector("#copyState"); +const diagnostic = document.querySelector("#diagnostic"); -async function check() { - if (checking) return; - checking = true; - status.textContent = `Connecting to OpenCodex proxy at 127.0.0.1:${port}…`; - retry.disabled = true; - try { - const response = await fetch(`${origin}/healthz`, { - cache: "no-store", - }); - if (response.ok) { - status.textContent = "Proxy is ready. Loading dashboard…"; - window.location.replace(dashboardUrl); - return; +const MARKS = { done: "✓", failed: "✕", active: "…", pending: "·" }; + +let phases = []; + +function render(progress) { + const completed = new Set((progress && progress.completed) || []); + const failedPhase = (progress && progress.failedPhase) || null; + const current = progress && progress.phase; + phaseList.replaceChildren(); + for (const phase of phases) { + let state = "pending"; + if (phase.id === failedPhase) { + state = "failed"; + } else if (phase.id === current) { + state = "active"; + } else if (completed.has(phase.id)) { + state = "done"; } - throw new Error(`HTTP ${response.status}`); + const row = document.createElement("li"); + row.dataset.state = state; + const mark = document.createElement("span"); + mark.className = "mark"; + mark.textContent = MARKS[state]; + const label = document.createElement("span"); + label.textContent = phase.label; + row.append(mark, label); + phaseList.append(row); + } +} + +function apply(progress) { + if (!progress) return; + headline.textContent = progress.label; + detail.textContent = progress.detail || ""; + const failed = progress.phase === "failed"; + failure.hidden = !failed; + retry.disabled = !progress.canRetry; + if (failed) { + diagnostic.value = progress.diagnostic || ""; + copyState.textContent = ""; + } + render(progress); +} + +function reportPageFailure(message, error) { + const cause = error && error.message ? error.message : String(error); + headline.textContent = "OpenCodex could not read its own startup state."; + detail.textContent = message; + failure.hidden = false; + retry.disabled = false; + diagnostic.value = [message, cause].join("\n"); +} + +async function copyDiagnostic() { + const text = diagnostic.value; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + copyState.textContent = "Copied to the clipboard."; + return; + } catch { + // A webview without clipboard access is the reason the text is on screen in the first place. + } + diagnostic.focus(); + diagnostic.select(); + let copied = false; + try { + copied = document.execCommand("copy"); } catch { - status.textContent = "The proxy is not reachable yet."; - } finally { - checking = false; - retry.disabled = false; + copied = false; + } + copyState.textContent = copied + ? "Copied to the clipboard." + : "The text above is selected — copy it with your keyboard."; +} + +retry.addEventListener("click", async () => { + if (!invoke) return; + copyState.textContent = ""; + retry.disabled = true; + try { + await invoke("retry_startup"); + } catch (error) { + reportPageFailure("The retry could not be sent to the shell.", error); + } +}); +copy.addEventListener("click", copyDiagnostic); + +async function start() { + if (!invoke || !listen) { + headline.textContent = "This page is the OpenCodex desktop shell's startup surface."; + detail.textContent = "Open it from the OpenCodex app."; + return; + } + try { + phases = (await invoke("startup_phases")).filter((phase) => !phase.terminal); + render(null); + // The listener goes on before the snapshot is read, so a transition landing between the two is + // delivered rather than lost. + await listen("startup-phase", (event) => apply(event.payload)); + apply(await invoke("startup_snapshot")); + } catch (error) { + reportPageFailure("The startup surface could not reach the shell.", error); } } -retry.addEventListener("click", check); -check(); -setInterval(check, 1500); +start(); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 543c53a5773..41daa0cd488 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -720,9 +720,15 @@ "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", + "desktop-exit-ownership.test.ts": "clients", + "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-startup-surface.test.ts": "clients", + "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-runtime-identity.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index cb5768a0c92..29408205c78 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -5,11 +5,141 @@ discovers the loopback proxy, lazily retries management authentication, starts the bundled `ocx` sidecar only when the configured endpoint is unreachable, and owns the tray, autostart, single-instance, and window lifecycle behavior. -`desktop/ui/` is only a short bootstrap page. Once `/healthz` answers, the shell -navigates the webview to the proxy's loopback dashboard -(`/#/usage`) rather than bundling or serving `gui/dist` itself. -Only the bootstrap page has Tauri IPC capability; the loopback dashboard never -does because `dangerousRemoteDomainIpcAccess` is not configured. +`desktop/ui/` is the startup surface. Once the runtime reports healthy the shell navigates the +webview to the proxy's loopback dashboard (`/#/usage`) rather than bundling or serving `gui/dist` +itself. The page renders what the shell tells it and probes nothing on its own; it asks +`startup_phases` for the state list rather than restating it, takes the current state from +`startup_snapshot` on load because the first states finish in milliseconds, and then follows the +`startup-phase` event. It uses no `alert`, `confirm` or `prompt`: the embedded webview implements +none of the matching WKUIDelegate panel methods on macOS, so a platform dialog is declined without +drawing anything. +`withGlobalTauri` is on so that page can invoke without a bundler. Only the local app origin +carries a capability, so the loopback dashboard reaches no command: `capabilities/default.json` +declares no `remote` entry, and Tauri checks the ACL for any invoke from a non-local origin. + +## Startup, quit and the tray + +The window is created and shown before anything is registered, resolved, probed or started, and +`desktop/src-tauri/src/startup.rs` runs the whole sequence inside it as named states — +registering, resolving, probing, attaching or starting, waiting, then ready or failed — under one +30-second deadline. Every call beneath that deadline is bounded by the time left rather than by its +own timeout, so the ceiling is the ceiling. The failure state carries a retry, the +child's exit code and a copyable diagnostic naming the state, the endpoint, the configuration home +and the runtime's last output; `desktop/src-tauri/src/sidecar.rs` consumes the spawn event stream +into that record instead of discarding it, which is what makes an immediate sidecar exit +distinguishable from a slow start. The page asks for the state list and the run's progress rather +than reconstructing either, because the early states finish faster than a listener can attach. + +The shell resolves nothing itself. Resolving runs the bundled `ocx resolve --json` and reads one +`ocx-resolve/1` document: the configuration home, the effective port, and a liveness verdict with +three answers rather than two. `live` means attach as a guest; `absent-proven` means every +recorded and configured endpoint was definitively dead, and **only that authorises starting a +runtime**. Everything else is unknown — a non-zero exit, a timeout, output that will not parse, a +schema this shell does not know, a missing binary — and unknown fails the state with a diagnostic +and a retry. It is never read as absence, because that reading is what put a second proxy next to +the one already running. This replaces a file that read `runtime-port.json`, fell back to 10100 and +started there, so a user with a configured `config.port` was started on a port they had not +chosen; the probe budgets that decision needs live in the CLI, where they were tuned. + +Registering runs first, before the runtime is touched. A login launch starts hidden, so a tray +installed only after a successful start would leave a failed start with no window and no icon. The +login item is registered in that state too, before the tray, so its Start at Login checkbox reads +the state first run leaves behind. A launch carrying the `--autostart` argument that the login +item passes back is the only one that starts hidden, and only where there is a tray to hide in: a +manual launch shows its window before the sequence begins, a login launch after the tray verdict. +Registering happens once per process, so a retry re-runs only the runtime half and cannot build a +second tray icon with its own refresh loop. + +`desktop/src-tauri/src/exit.rs` owns what ends the process. Where there is a usable tray, closing +the window and the platform's quit gesture both hide; only the tray's Quit asks to end, and an +installed update asks for a coordinated restart. Where there is no usable tray, closing the window +is the quit. macOS needs one thing beyond the event loop: Tauri's default menu carries a predefined +Quit wired to Cocoa's `terminate:` and the pinned tao raises no cancellable event for it, so +`desktop/src-tauri/src/menu.rs` rebuilds that menu with an ordinary item on the same accelerator. + +Every ending drains first, and so does the tray's Stop, which is not an ending: all of them take the +same phase, so Stop pressed twice, Stop then Quit, and Stop during an update are one execution over +one child rather than several racing. Ownership is re-established at the start of each drain rather +than read off a flag — the pid the endpoint reports has to be the child this app started — because +between the spawn and now the child can have exited and a service can have taken the port back, and +an owner's stop sent to that listener is a stop sent to somebody else's runtime. A listener that +cannot be identified is left alone. + +A runtime counts as gone only when the child reports its own exit or the endpoint refuses a +connection; a timeout or an unauthorized reply is not proof. The stop itself is the bundled +`ocx stop --json`, not a management call from inside this process: the CLI's stop owns the +receipt-backed teardown, the drain, the Windows respawn verification and the client-configuration +restore, and an in-process endpoint cannot own its own teardown because launchd and systemd can +terminate the request handler during self-unload. The shell reads that run's `ocx-stop/1` summary +rather than inferring it, and treats a stop as done only when the CLI reported exit 0 **and** that +no proxy of this home is left running. A service that failed while the proxy happened to stop +satisfies the second and not the first, and it is exactly the case that may respawn the runtime a +moment later. Nothing kills the child. + +A drain that does not complete within `DRAIN_DEADLINE` is **not** recorded as a drain. It becomes +`DrainFailed`, and an unidentifiable runtime becomes `OwnershipUnknown`. A user's quit still +proceeds from either — refusing to close when the user asked is the worse answer, and a standing +runtime is recoverable with `ocx stop`. A coordinated restart does not: coming back onto a runtime +that was never stopped puts the user on the old version while they believe they upgraded. A runtime +this app did not start is never stopped. A quit that arrives while the sequence is starting one is +held: the coordinator reserves the spawn rather than holding its lock across process creation, and +the quit is deferred until the child is owned and then drains it. + +An in-app update downloads and signature-checks the package, confirms who owns the running runtime, +drains it and confirms the child is gone, and only then installs. The order is not cosmetic: the +pinned updater's Windows installer hands off to the installer process and ends this one, so a +restart asked for after `install` is never reached, and the package would be replaced under a +runtime still serving out of those files. A drain that did not complete refuses the install and +leaves the update pending. + +The window may navigate to the `tauri://` scheme, to the loopback endpoint the sequence resolved, +and on Windows to `tauri.localhost`, which is where the pinned Tauri serves the app itself because +wry needs an http origin there. That is the one host and no port — not localhost generally, and not +a widening of what the loopback dashboard may reach. + +`desktop/src-tauri/src/proxy.rs` is the local management client and has its own network policy, +separate from the updater's download client. It refuses redirects and system proxies, and it will +not send the management token until it has confirmed, from the unauthenticated health body, that the +instance answering is the one the shell bound to: the marker, the pid, and the port it addressed. +The binding carries a generation, so a request authorised under an earlier binding is not authorised +after the shell rebinds. + +`desktop/src-tauri/src/tray_availability.rs` asks the session bus whether +`org.kde.StatusNotifierWatcher` reports a host registered; macOS and Windows answer yes without a +probe. Neither construction success nor the watcher's mere existence is the question — the pinned +Linux backend creates an AppIndicator and reports success with no host attached, and a watcher with +no host accepts registrations and draws nothing. Until the probe answers, Linux assumes no tray, so +a window closed in the first moments quits rather than vanishing, and the verdict is published only +once an icon actually exists — a tray that fails to build is a session with no tray, not a claimed +one. Where the answer is no, no tray icon is claimed, the window is shown on launch whatever the +launch origin, and closing it quits through the same drain. The update controls live in the tray +menu, so a session without one checks for updates in the background and has no place to install +them from. + +Every tray menu setter dispatches to the main thread and waits for it, and the tray is built on the +main thread while holding the menu mutex, so the handles are copied out from under that mutex before +any setter is called. Holding it across a setter is a cycle, and the symptom would be an app that +stops answering Quit. + +## Runtime ownership, from the app's side + +`desktop/src-tauri/src/identity.rs` holds this installation's own install id: an opaque value minted +once into the app's config directory and never rewritten, exclusively so two launches racing each +other answer to the same one. It exists because the recorded claim names the owning *installation*, +so the app needs a value of its own to compare against; an id kept only in the shared record would +be whoever wrote it last, and a reinstalled app could not tell its own prior consent from another +installation's. The cost is that a reinstall which keeps the directory keeps its consent and one +that loses it asks again. + +`desktop/src-tauri/src/ownership.rs` mirrors the claim, the three answers a read can give and the +comparison, all of which are defined by +[background-service runtime ownership](runtime.md#background-service-runtime-ownership) and not +here. The shell does not read the record: resolving a claim means reading every state path and +failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second +weaker implementation of a question core already answers is the mistake this tree has made before. +The bundled CLI answers it. Until that contract lands, `resolve` returns *unavailable*, which is +not the same as "nobody owns it" — the question has not been put — so the shell attempts no takeover +and records nothing, and the startup state and the diagnostic say which of the two it is. `desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, before the tray is built so its checkbox reads the resulting state. A menu bar app diff --git a/structure/overview.md b/structure/overview.md index 55b6f89b21c..5c226f2dfc0 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -170,6 +170,28 @@ still cover the rule, which is a judgement only review makes. pages are compared against their English source, so a changed default fails a check instead of leaving two documents to disagree; see [`chat-compat.md`](providers/chat-compat.md). Enforced by `tests/ci-workflows/docs-developer-role-policy.test.ts`. +- **INV-DESKTOP-01** — Where the desktop app has a usable tray, only the tray's Quit ends it: + closing the window and the platform's quit gesture hide, which on macOS needs the default menu's + predefined Quit replaced because it raises no cancellable event. Every ending drains first — the + tray's Quit, an update's coordinated restart, and a window close on a session with no tray all + hold the exit, stop the app-owned runtime through the management stop, and treat only an observed + child exit or a refused connection as proof it stopped. The stop is the bundled `ocx stop --json`, + accepted only on exit 0 with the runtime reported down. Ownership is re-established from the pid + the endpoint reports rather than carried in a flag, a drain that does not complete is recorded as + failed rather than drained — which a quit tolerates and a coordinated restart refuses — and an + update installs only after the runtime it is replacing is confirmed stopped. No shell file kills + the child, a runtime this app did not start is never stopped, and a quit that lands while one is + being started or stopped is deferred rather than lost; + see [`desktop-shell.md`](desktop-shell.md). + Enforced by `tests/clients/desktop-exit-ownership.test.ts`. +- **INV-DESKTOP-02** — Tray availability is an answer from the session, not the tray backend's + construction result and not the watcher's mere existence: the shell asks whether + `org.kde.StatusNotifierWatcher` reports a host registered, and reads an unanswerable probe the + same way as an unregistered one. Linux assumes no tray until the probe answers, and the verdict is + published only once an icon exists, so a tray that fails to build is a session without one. Where + there is none, no icon is claimed, the window is shown on launch whatever the launch origin, and + closing it quits through the same drain; see [`desktop-shell.md`](desktop-shell.md). + Enforced by `tests/clients/desktop-tray-availability.test.ts`. CI enumerates that domain layout through `scripts/ci/run-bun-test-batches.sh`. Its default general scope and 12-file/120-second process shape leave the dedicated Linux storage-policy and api-usage diff --git a/tests/clients/desktop-cli-contracts.test.ts b/tests/clients/desktop-cli-contracts.test.ts new file mode 100644 index 00000000000..d4ae062c866 --- /dev/null +++ b/tests/clients/desktop-cli-contracts.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The two CLI surfaces the shell drives, read against the CLI that defines them. + * + * D5 and D4: the shell stops resolving the home, the port and liveness itself, and stops performing + * the teardown itself. `src/cli/resolve.ts` and `src/cli/stop-report.ts` own those answers; the Rust + * side is a reader. Both halves are asserted here together so a schema, a status value or an + * outcome name cannot change on one side and be discovered on a user's machine. + * + * The rule that matters most is the one a reader can get wrong quietly: liveness has three answers, + * and only a proven absence authorises starting a runtime. Everything that can go wrong on the + * shell side has to fold into the third one, because the reading that must never happen is "the + * resolve failed, so nobody must be listening". + */ +const SHELL = "desktop/src-tauri/src"; +const RESOLVE_RS = repoPath(`${SHELL}/resolve.rs`); +const STOP_RS = repoPath(`${SHELL}/runtime_stop.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const RESOLVE_TS = repoPath("src/cli/resolve.ts"); +const STOP_TS = repoPath("src/cli/stop-report.ts"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop CLI contracts", () => { + const resolveRs = code(RESOLVE_RS); + const stopRs = code(STOP_RS); + const resolveTs = code(RESOLVE_TS); + const stopTs = code(STOP_TS); + + test("both sides name the same wire versions", () => { + expect(resolveTs).toContain('RESOLVE_SCHEMA = "ocx-resolve/1"'); + expect(resolveRs).toContain('pub const SCHEMA: &str = "ocx-resolve/1"'); + expect(stopTs).toContain('STOP_SUMMARY_SCHEMA = "ocx-stop/1"'); + expect(stopRs).toContain('pub const SCHEMA: &str = "ocx-stop/1"'); + // A document announcing anything else is not understood rather than half-read. + expect(resolveRs).toContain("resolved.schema != SCHEMA"); + expect(stopRs).toContain("summary.schema != SCHEMA"); + }); + + test("liveness keeps its three answers, and only one of them authorises a start", () => { + // Two reach the wire; the third exits 1 before the document is built. + expect(resolveTs).toContain('status: "live" | "absent-proven"'); + expect(resolveRs).toContain('#[serde(rename_all = "kebab-case")]'); + expect(resolveRs).toContain(" Live,"); + expect(resolveRs).toContain(" AbsentProven,"); + const rule = resolveRs.slice(resolveRs.indexOf("pub fn may_start(")); + const body = rule.slice(0, rule.indexOf("\n}")); + expect(body).toContain("Some(Status::AbsentProven)"); + expect(body).not.toContain("Status::Live"); + }); + + test("a live listener this app cannot manage is neither attached to nor started beside", () => { + // Core's liveness predicate accepts a connected client's listener on purpose, so + // duplicate-start avoidance can see it; a caller that needs the management plane has to + // discriminate on the role rather than narrow that predicate. + expect(resolveTs).toContain("role?: string"); + const verdict = resolveRs.slice(resolveRs.indexOf("pub fn live_verdict(")); + const body = verdict.slice(0, verdict.indexOf("\n}")); + expect(body).toContain('role.as_deref() == Some("client")'); + expect(body).toContain("LiveVerdict::Unusable"); + // And an address this shell cannot reach on loopback is the same kind of answer. + expect(body).toContain("loopback_reachable(resolved.liveness.hostname.as_deref())"); + const startup = code(STARTUP); + const unusable = startup.indexOf("resolve::LiveVerdict::Unusable(reason) =>"); + const spawn = startup.indexOf("spawn_runtime(app, endpoint, &watch)"); + expect(unusable).toBeGreaterThan(-1); + expect(startup.slice(unusable, spawn)).toContain("return;"); + }); + + test("everything that can go wrong on this side folds into unknown", () => { + const reader = resolveRs.slice(resolveRs.indexOf("pub fn read("), resolveRs.indexOf("pub async fn run(")); + // A non-zero exit is the CLI's own refusal, including the exit 1 it uses for unknown liveness. + expect(reader).toContain("if exit_code != Some(0) {"); + expect(reader).toContain("Resolution::Unknown"); + const runner = resolveRs.slice(resolveRs.indexOf("pub async fn run(")); + const body = runner.slice(0, runner.indexOf("\n}")); + // A missing binary, a failed spawn and a deadline all answer the same way. + expect(body.match(/Resolution::Unknown/g) || []).toHaveLength(3); + expect(body).toContain("timeout_at(deadline, command.output())"); + }); + + test("the startup sequence refuses to start on anything but a proven absence", () => { + const startup = code(STARTUP); + const run = startup.slice(startup.indexOf("async fn run(app: &AppHandle)")); + const unknown = run.indexOf("let Some(answer) = resolution.resolved() else {"); + const attach = run.indexOf("match resolve::live_verdict(&resolution) {"); + const guard = run.indexOf("if !resolve::may_start(&resolution) {"); + const spawn = run.indexOf("spawn_runtime(app, endpoint, &watch)"); + expect(unknown).toBeGreaterThan(-1); + expect(attach).toBeGreaterThan(unknown); + expect(guard).toBeGreaterThan(attach); + expect(spawn).toBeGreaterThan(guard); + // The unresolved branch fails the state; it does not fall through to a start. + expect(run.slice(unknown, attach)).toContain("Phase::Resolving,"); + expect(run.slice(unknown, attach)).toContain("return;"); + }); + + test("a stop is a success only when the CLI said so twice", () => { + // The process status and the document have to agree, and the document has to say the runtime + // is down: taking the summary's word for its own exit status is taking a claim as its own + // evidence. + expect(stopTs).toContain("ok: signals.exitCode === 0"); + expect(stopTs).toContain("runtimeDown: record.proxy ==="); + const reader = stopRs.slice(stopRs.indexOf("pub fn read("), stopRs.indexOf("pub async fn run(")); + expect(reader).toContain("if exit_code != Some(0)"); + expect(reader).toContain("|| !summary.ok"); + expect(reader).toContain("|| summary.exit_code != 0"); + expect(reader).toContain("|| !summary.runtime_down"); + // And the document has to agree with itself rather than be trusted to. + expect(reader).toContain("|| !agrees"); + expect(reader).toContain("(Outcome::Stopped, Proxy::Stopped)"); + expect(reader).toContain("(Outcome::NotRunning, Proxy::NotRunning)"); + expect(reader).toContain("StopResult::Failed"); + const stopped = reader.indexOf("StopResult::Stopped(summary)"); + expect(stopped).toBeGreaterThan(reader.indexOf("if exit_code != Some(0)")); + }); + + test("the outcomes the shell can be handed are the outcomes the CLI can emit", () => { + expect(stopTs).toContain( + 'outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed"', + ); + // The shell does not re-derive the outcome; it carries the CLI's own words into its diagnostic. + expect(stopRs).toContain("summary.outcome"); + expect(stopRs).toContain("summary.exit_code"); + expect(stopRs).toContain("summary.message"); + expect(stopRs).not.toContain('== "stopped"'); + }); +}); diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts new file mode 100644 index 00000000000..27cc075f3fa --- /dev/null +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * INV-DESKTOP-01 — only the tray's Quit ends the app, and nothing ends the runtime by force. + * + * Three gestures used to mean the same thing. Closing the window hid it, but Cmd+Q reached + * `RunEvent::Exit` with no `ExitRequested` handler in between, and that called + * `CommandChild::kill()` — a SIGKILL on Unix — on the runtime this app had started. The CLI's own + * stop restores client configuration, lets in-flight requests finish and clears state files; none + * of that survived a keystroke the user reads as "hide". The updater took the same path. + * + * macOS needs one thing more than the event handler, and it is the part that is easiest to get + * wrong while believing it works: Tauri's default menu carries a predefined Quit wired straight to + * Cocoa's `terminate:`, and the pinned tao raises no cancellable event for it, so `prevent_exit` + * never sees it. The replacement item is therefore part of this contract, not a detail. + * + * The wiring is the contract and it is not visible from behaviour alone — CI builds the shell + * against a zero-byte sidecar and has no session to press Cmd+Q in — so it is read out of the + * source, the way the Start at Login ordering already is. + */ +const SRC = "desktop/src-tauri/src"; +const LIB = repoPath(`${SRC}/lib.rs`); +const EXIT = repoPath(`${SRC}/exit.rs`); +const MENU = repoPath(`${SRC}/menu.rs`); +const TRAY = repoPath(`${SRC}/tray.rs`); +const WINDOW = repoPath(`${SRC}/window.rs`); +const UPDATER = repoPath(`${SRC}/updater.rs`); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +/** Every Rust file in the shell, walked from disk so a new module cannot opt itself out. */ +function shellSources(directory: string = SRC): string[] { + return readdirSync(repoPath(directory), { withFileTypes: true }).flatMap((entry) => { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) return shellSources(path); + return entry.name.endsWith(".rs") ? [path] : []; + }); +} + +describe("desktop exit ownership", () => { + test("the event loop intercepts the exit request instead of letting it through", () => { + const lib = code(LIB); + expect(lib).toContain("RunEvent::ExitRequested"); + expect(lib).toContain("exit::on_exit_requested(app, code, &api)"); + }); + + test("no file in the shell kills the runtime process", () => { + const sources = shellSources(); + expect(sources.length).toBeGreaterThan(10); + for (const source of sources) { + expect(code(repoPath(source))).not.toContain(".kill()"); + } + }); + + test("the tray's Quit asks the coordinator rather than ending the process itself", () => { + const tray = code(TRAY); + expect(tray).toContain('"quit" => exit::request(app, ExitReason::UserQuit)'); + expect(tray).not.toContain("app.exit("); + }); + + test("the macOS menu's Quit is an ordinary item routed through the gesture path", () => { + const menu = code(MENU); + // The predefined item is the one that cannot be held: it calls Cocoa's terminate: directly. + expect(menu).not.toContain("PredefinedMenuItem::quit"); + expect(menu).toContain('Some("CmdOrCtrl+Q")'); + expect(menu).toContain("crate::exit::gesture(app)"); + // Losing the default menu would take the clipboard items with it, and the failure diagnostic + // is a block of text the user is asked to copy. + for (const item of ["cut", "copy", "paste", "select_all"]) { + expect(menu).toContain(`PredefinedMenuItem::${item}`); + } + const lib = code(LIB); + expect(lib).toContain(".menu(menu::build)"); + expect(lib).toContain("menu::on_event(app, event.id().as_ref())"); + }); + + test("a restart is told apart from a quit and keeps its own reason", () => { + const exit = code(EXIT); + expect(exit).toContain("ExitReason::CoordinatedRestart"); + expect(exit).toContain("tauri::RESTART_EXIT_CODE"); + const updater = code(UPDATER); + expect(updater).toContain("crate::exit::prepare_restart(app).await"); + expect(updater).not.toContain("app.restart()"); + }); + + test("an update stops the runtime before it replaces anything", () => { + const updater = code(UPDATER); + const install = updater.slice(updater.indexOf("pub async fn install(")); + const body = install.slice(0, install.indexOf("\n}")); + const downloaded = body.indexOf(".download("); + const prepared = body.indexOf("crate::exit::prepare_restart(app).await"); + const installed = body.indexOf("update.install(package)"); + expect(downloaded).toBeGreaterThan(-1); + expect(prepared).toBeGreaterThan(downloaded); + expect(installed).toBeGreaterThan(prepared); + // The combined call is the shape that cannot drain first. + expect(body).not.toContain("download_and_install"); + // A drain that did not complete refuses the install rather than proceeding. + expect(body.slice(prepared, installed)).toContain("if readiness != RestartReadiness::Ready {"); + expect(body.slice(prepared, installed)).toContain("return Err("); + }); + + test("an update restart drains through the same path a quit does", () => { + const exit = code(EXIT); + const prepare = exit.indexOf("pub async fn prepare_restart"); + expect(prepare).toBeGreaterThan(-1); + const body = exit.slice(prepare, exit.indexOf("pub fn complete_restart", prepare)); + expect(body).toContain("claim_drain(ExitReason::CoordinatedRestart)"); + expect(body).toContain("drain_current(app).await"); + expect(body).toContain("coordinator.finish_drain(verdict)"); + // D4: the stop is the bundled CLI's, which owns the receipt-backed teardown this process + // cannot perform on itself. + expect(exit).toContain("runtime_stop::run(app, deadline).await"); + }); + + test("a failed drain is not recorded as a drain, and a restart refuses it", () => { + const exit = code(EXIT); + const record = exit.slice(exit.indexOf("pub fn finish_drain(")); + const body = record.slice(0, record.indexOf("\n }")); + expect(body).toContain("DrainVerdict::Drained => ExitPhase::Drained"); + expect(body).toContain("DrainVerdict::Failed => ExitPhase::DrainFailed"); + expect(body).toContain("DrainVerdict::OwnershipUnknown => ExitPhase::OwnershipUnknown"); + const rule = exit.slice(exit.indexOf("pub fn decide("), exit.indexOf("struct Inner {")); + expect(rule).toContain("ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => match reason"); + expect(rule).toContain("Some(ExitReason::CoordinatedRestart) => ExitDecision::Refuse"); + // A quit still closes the app on one, which is the trade that is defensible. + expect(rule).toContain("_ => ExitDecision::Proceed"); + // And the refusal is recoverable: the update stayed pending, so the next attempt runs the + // stop again rather than finding the app permanently unable to try. + const claim = exit.slice(exit.indexOf("pub fn claim_drain"), exit.indexOf("pub fn finish_drain")); + expect(claim).toContain("ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => {"); + expect(claim).toContain("ExitPhase::Draining | ExitPhase::Drained => None,"); + }); + + test("stop, quit and update are one execution over one child", () => { + const tray = code(TRAY); + expect(tray).toContain("exit::request_stop(app)"); + expect(tray).not.toContain("sidecar::drain"); + const exit = code(EXIT); + const stop = exit.slice(exit.indexOf("pub fn request_stop(")); + const body = stop.slice(0, stop.indexOf("\n}")); + expect(body).toContain("coordinator.begin_stop()"); + expect(body).toContain("drain_current(&app).await"); + // A quit that landed during the stop is handed back and run, not dropped. + expect(body).toContain("coordinator.finish_stop()"); + expect(body).toContain("drain_now(&app, reason)"); + }); + + test("the reason and the drain are claimed in one step", () => { + const exit = code(EXIT); + const claim = exit.indexOf("pub fn claim_drain"); + expect(claim).toBeGreaterThan(-1); + const body = exit.slice(claim, exit.indexOf("pub fn finish_drain", claim)); + expect(body.length).toBeGreaterThan(0); + // One match over the phase, so the reason a caller wins and the move out of Idle cannot be + // separated by a second caller arriving between them. + expect(body).toContain("match inner.phase {"); + const idle = body.indexOf("ExitPhase::Idle => {"); + expect(idle).toBeGreaterThan(-1); + const arm = body.slice( + idle, + body.indexOf("ExitPhase::Spawning | ExitPhase::Stopping => {", idle), + ); + expect(arm).toContain("inner.reason.get_or_insert(fallback)"); + expect(arm).toContain("inner.phase = ExitPhase::Draining;"); + // Nothing else in the file moves the phase to draining. + expect(exit.split("inner.phase = ExitPhase::Draining;")).toHaveLength(4); + }); + + test("a quit that lands while a runtime is starting is deferred, not lost", () => { + const exit = code(EXIT); + // The lock is never held across process creation, so the main thread's exit handler cannot + // end up waiting on a spawn; the exit is held by the phase instead. + expect(exit).toContain("ExitPhase::Spawning"); + expect(exit).toContain( + "ExitPhase::Spawning | ExitPhase::Stopping | ExitPhase::Draining => ExitDecision::Wait", + ); + const claim = exit.slice(exit.indexOf("pub fn claim_drain"), exit.indexOf("pub fn finish_drain")); + expect(claim).toContain("ExitPhase::Spawning | ExitPhase::Stopping => {"); + expect(claim).toContain("inner.deferred = true;"); + const finish = exit.slice( + exit.indexOf("fn finish(&self, phase: ExitPhase)"), + exit.indexOf("impl Default for ExitCoordinator"), + ); + expect(finish).toContain("inner.deferred"); + expect(finish).toContain("inner.phase = ExitPhase::Draining;"); + const startup = code(repoPath(`${SRC}/startup.rs`)); + const spawn = startup.slice(startup.indexOf("fn spawn_runtime(")); + expect(spawn).toContain("if !coordinator.begin_spawn() {"); + expect(spawn).toContain("crate::exit::drain_now(app, reason)"); + expect(spawn.indexOf("state.adopt(child)")).toBeLessThan(spawn.indexOf("coordinator.finish_spawn()")); + }); + + test("no tray setter is called while the tray mutex is held", () => { + const tray = code(repoPath(`${SRC}/tray.rs`)); + // Those setters dispatch to the main thread and wait for it, and the tray is built on the main + // thread while holding this mutex, so the two together are a cycle. + expect(tray).toContain("fn menu_handles(app: &AppHandle) -> Option"); + // Every setter is reached through the copy, never through a live guard: the nearest thing + // before it is the handle copy, not the lock. + const setters = [...tray.matchAll(/\.(?:set_enabled|set_text)\(/g)]; + expect(setters.length).toBeGreaterThan(5); + for (const setter of setters) { + const before = tray.slice(0, setter.index); + expect(before.lastIndexOf("menu_handles(app)")).toBeGreaterThan( + before.lastIndexOf("menu.lock()"), + ); + } + }); + + test("the exit is held until the drain reports", () => { + const exit = code(EXIT); + const handler = exit.slice( + exit.indexOf("pub fn on_exit_requested"), + exit.indexOf("pub fn start_drain"), + ); + expect(handler.length).toBeGreaterThan(0); + for (const arm of [ + "ExitDecision::Hide =>", + "ExitDecision::Wait =>", + "ExitDecision::Drain(reason) =>", + ]) { + const at = handler.indexOf(arm); + expect(at).toBeGreaterThan(-1); + expect(handler.slice(at, at + 160)).toContain("api.prevent_exit()"); + } + const proceed = handler.indexOf("ExitDecision::Proceed =>"); + expect(proceed).toBeGreaterThan(-1); + expect(handler.slice(proceed)).not.toContain("prevent_exit"); + expect(exit).toContain("coordinator.finish_drain(verdict)"); + }); + + test("closing the window takes the same decision the quit gesture does", () => { + const window = code(WINDOW); + const close = window.indexOf("CloseRequested"); + expect(close).toBeGreaterThan(-1); + const branch = window.slice(close, window.indexOf("});", close)); + expect(branch).toContain("api.prevent_close()"); + expect(branch).toContain("exit::gesture("); + const exit = code(EXIT); + const gesture = exit.slice(exit.indexOf("pub fn gesture(")); + const body = gesture.slice(0, gesture.indexOf("\n}")); + expect(body).toContain("ExitDecision::Hide => hide_windows(app)"); + expect(body).toContain("ExitDecision::Drain(reason) => start_drain(app, reason)"); + }); + + test("a drain never runs against a runtime this app did not start", () => { + const exit = code(EXIT); + const drain = exit.slice(exit.indexOf("pub async fn drain_current(")); + const body = drain.slice(0, drain.indexOf("\nenum Ownership")); + // Only a confirmed-ours runtime is stopped. A foreign one is left alone, and one that cannot + // be identified stops nothing at all. + expect(body).toContain("Ownership::Foreign => DrainVerdict::Drained"); + expect(body).toContain("Ownership::Unknown => DrainVerdict::OwnershipUnknown"); + expect(body.indexOf("Ownership::Ours =>")).toBeLessThan(body.indexOf("runtime_stop::run")); + // Nothing in the shell performs the stop itself any more. + expect(code(repoPath(`${SRC}/sidecar.rs`))).not.toContain("fn drain("); + expect(code(repoPath(`${SRC}/proxy.rs`))).not.toContain("Method::POST"); + }); + + test("only an observed exit or a refused connection proves the runtime stopped", () => { + const exit = code(EXIT); + const start = exit.indexOf("async fn confirm("); + expect(start).toBeGreaterThan(-1); + const body = exit.slice(start, exit.indexOf("fn hide_windows(", start)); + expect(body).toContain("watch.exit().is_some()"); + expect(body).toContain("error.is_unreachable()"); + // Any-error-means-gone is the shape this replaces. + expect(exit).not.toContain("proxy.is_alive().await.is_err()"); + }); +}); diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts new file mode 100644 index 00000000000..797b2192e1b --- /dev/null +++ b/tests/clients/desktop-install-identity.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The desktop app's half of the runtime-ownership claim. + * + * The claim lives in the shared service install state, which core owns across two files: the + * validation that decides what a record may say is in `src/service/install-state-contract.mjs`, + * and the types, the three answers a read can give and `ownershipGrantedTo` — the comparison an + * installation applies to its own locally stored install id — are in `src/service/state.ts`. The + * shell holds the other half, an id of its own to compare against, and mirrors the rule rather + * than inventing one, because a weaker version of a question core already answers is how the + * shell ended up guessing a port it should have been told. + * + * Both halves are read here together, so a change on either side breaks this rather than leaving + * the two to disagree in a place only a takeover would reveal. + */ +const SHELL = "desktop/src-tauri/src"; +const IDENTITY = repoPath(`${SHELL}/identity.rs`); +const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const STATE = repoPath("src/service/state.ts"); +const CONTRACT = repoPath("src/service/install-state-contract.mjs"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop install identity", () => { + const identity = code(IDENTITY); + const ownership = code(OWNERSHIP); + const state = code(STATE); + const contract = code(CONTRACT); + + test("the installation's id is minted once and never rewritten", () => { + // Exclusive, because two launches racing to mint would answer to two ids, and the second one + // would find a claim that is not its own and ask again for consent already given. + expect(identity).toContain(".create_new(true)"); + const mint = identity.slice(identity.indexOf("pub fn install_id_in")); + const body = mint.slice(0, mint.indexOf("\n}")); + expect(body.indexOf("if let Some(existing) = read(&path)")).toBeLessThan( + body.indexOf("mint(&path)"), + ); + expect(body).toContain("return Some(existing);"); + // It is the app's own directory, not the shared record: an id stored only in the shared one + // would be whoever wrote it last. + expect(identity).toContain("app_config_dir()"); + }); + + test("a blank record is replaced rather than answered with", () => { + expect(identity).toContain("ErrorKind::AlreadyExists"); + const replace = identity.slice(identity.indexOf("ErrorKind::AlreadyExists")); + expect(replace.slice(0, 300)).toContain("read(&path).is_none()"); + }); + + test("the owner values are the ones the record accepts", () => { + // Both halves of core's answer are read. The runtime rejection is what a record on disk meets, + // and the exported type is what every caller is compiled against; a parse that accepted a + // third owner and a type that forbade it would disagree exactly where a takeover happens. + expect(contract).toContain('value.owner !== "cli" && value.owner !== "desktop"'); + expect(state).toContain('export type ServiceOwner = "cli" | "desktop"'); + expect(ownership).toContain('#[serde(rename_all = "lowercase")]'); + expect(ownership).toContain(" Cli,"); + expect(ownership).toContain(" Desktop,"); + }); + + test("the claim's wire fields are the recorded ones", () => { + for (const field of ["installId", "consentGeneration"]) { + expect(state).toContain(`ownership.${field}`); + } + expect(ownership).toContain('#[serde(rename_all = "camelCase")]'); + expect(ownership).toContain("pub install_id: String"); + expect(ownership).toContain("pub consent_generation: u64"); + }); + + test("the three answers a read can give are all three", () => { + for (const kind of ["none", "owned", "unknown"]) { + expect(state).toContain(`kind: "${kind}"`); + } + expect(ownership).toContain('#[serde(tag = "kind", rename_all = "lowercase")]'); + expect(ownership).toContain(" None,"); + expect(ownership).toContain("Owned { ownership: Claim }"); + expect(ownership).toContain("Unknown { reason: String }"); + }); + + test("the comparison is the one the record publishes, and no more", () => { + const rule = state.slice(state.indexOf("export function ownershipGrantedTo")); + expect(rule.slice(0, 300)).toContain( + "ownership.owner === owner && ownership.installId === installId", + ); + const mirror = ownership.slice(ownership.indexOf("pub fn granted_to")); + const body = mirror.slice(0, mirror.indexOf("\n}")); + expect(body).toContain("claim.owner == owner && claim.install_id == install_id"); + // The generation moves on every grant; comparing it would make a held consent look foreign. + expect(body).not.toContain("consent_generation"); + }); + + test("an unreadable record refuses instead of reading as unowned", () => { + const verdict = ownership.slice(ownership.indexOf("pub fn consent(")); + const body = verdict.slice(0, verdict.indexOf("\n}")); + expect(body).toContain("Recorded::Unknown { .. } => Consent::Refuse"); + expect(body).toContain("Recorded::None => Consent::AskFirstTime"); + }); + + test("the shell does not read the recorded claim itself", () => { + // Resolving means reading every state path and failing closed on an unreadable one, a corrupt + // anchor and paths that disagree. That answer belongs to the CLI. + for (const leak of ["service-state", "serviceStatePaths", "read_to_string", "fs::"]) { + expect(ownership).not.toContain(leak); + } + const seam = ownership.slice(ownership.indexOf("pub fn resolve(_app: &AppHandle)")); + expect(seam.slice(0, 120)).toContain("None"); + }); + + test("not having asked is distinct from nobody owning it", () => { + // Option::None means the CLI has not been asked; Recorded::None means it answered that no + // claim exists. Collapsing them would let a takeover proceed on a question never put. + expect(ownership).toContain("pub fn resolve(_app: &AppHandle) -> Option"); + expect(ownership).toContain("(None, _) => "); + const startup = code(STARTUP); + expect(startup).toContain("ownership::describe(ownership::resolve(app).as_ref()"); + expect(startup).toContain("identity::install_id(app)"); + expect(startup).toContain('format!("runtime ownership: {}", registration.identity)'); + }); +}); diff --git a/tests/clients/desktop-runtime-identity.test.ts b/tests/clients/desktop-runtime-identity.test.ts new file mode 100644 index 00000000000..8ce8f2de491 --- /dev/null +++ b/tests/clients/desktop-runtime-identity.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * Which instance the shell is talking to, and what it will send there. + * + * The management token is the admin credential for this machine's proxy, and the endpoint is a + * port a local process can take. So identity comes first, from the unauthenticated health body the + * runtime already publishes — the marker, the pid and the port — and the credential follows only + * for the instance the shell decided to trust. The same facts answer a second question the shell + * used to answer with a boolean: whether the process holding the port is the child it started. + * + * Both are read out of the source, because CI has no running proxy to address and no Windows + * webview to navigate. + */ +const SHELL = "desktop/src-tauri/src"; +const PROXY = repoPath(`${SHELL}/proxy.rs`); +const EXIT = repoPath(`${SHELL}/exit.rs`); +const LIB = repoPath(`${SHELL}/lib.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const WINDOW = repoPath(`${SHELL}/window.rs`); +const SERVE = repoPath("src/server/index/serve-options.ts"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop runtime identity", () => { + const proxy = code(PROXY); + const exit = code(EXIT); + + test("the identity it reads is the one the runtime publishes", () => { + // Unauthenticated, so it can be read before anything secret is sent. + const health = code(SERVE); + for (const field of ['service: "opencodex"', "pid: process.pid", "port: healthPort"]) { + expect(health).toContain(field); + } + const reader = proxy.slice(proxy.indexOf("pub fn identity_from(")); + const body = reader.slice(0, reader.indexOf("\n}")); + expect(body).toContain('body.get("service")'); + expect(body).toContain('body.get("pid")'); + expect(body).toContain('body.get("port")'); + // A 200 from something else on the port is not this proxy, and a body describing a different + // listener does not authorise a credential for this one. + expect(body).toContain("if port != addressed_port {"); + }); + + test("the credential is never sent to an unconfirmed instance", () => { + const start = proxy.indexOf("async fn authorised_token("); + expect(start).toBeGreaterThan(-1); + const body = proxy.slice(start, proxy.indexOf("async fn send(", start)); + expect(body).toContain("let Some(binding) = self.binding() else"); + // Re-confirmed here, not trusted from when it was made: in between, the child can exit and + // something else can hold the port. + expect(body).toContain("let identity = self.identify().await?;"); + expect(body).toContain("if identity != binding.identity"); + expect(body).toContain("if self.binding() != Some(binding)"); + const token = body.indexOf("self.auth.token()"); + expect(token).toBeGreaterThan(body.indexOf("if self.binding() != Some(binding)")); + }); + + test("a request is bound to the pid, the port and the generation it was authorised under", () => { + expect(proxy).toContain("pub struct RuntimeIdentity {"); + expect(proxy).toContain("pub pid: u32"); + expect(proxy).toContain("pub port: u16"); + expect(proxy).toContain("pub struct RuntimeBinding {"); + expect(proxy).toContain("pub generation: u64"); + const bind = proxy.slice(proxy.indexOf("pub fn bind(")); + expect(bind.slice(0, bind.indexOf("\n }"))).toContain("*generations += 1;"); + }); + + test("the local management client refuses redirects and system proxies", () => { + const builder = proxy.slice(proxy.indexOf("Client::builder()"), proxy.indexOf(".build()?")); + expect(builder).toContain("redirect(redirect::Policy::none())"); + expect(builder).toContain(".no_proxy()"); + }); + + test("attaching to a runtime does not carry ownership of the last one", () => { + const lib = code(LIB); + const attach = lib.slice(lib.indexOf("pub fn attach(")); + const body = attach.slice(0, attach.indexOf("\n }")); + expect(body).toContain("self.confirmed.store(false, Ordering::Release)"); + // A spawn records a pid; it does not record that the pid is the one holding the port. + const adopt = lib.slice(lib.indexOf("pub fn adopt(")); + expect(adopt.slice(0, adopt.indexOf("\n }"))).toContain( + "self.confirmed.store(false, Ordering::Release)", + ); + const confirm = lib.slice(lib.indexOf("pub fn confirm_ownership(")); + expect(confirm.slice(0, confirm.indexOf("\n }"))).toContain( + "self.child_pid() == Some(identity.pid)", + ); + }); + + test("ownership is confirmed from the answering pid before anything is stopped", () => { + const start = exit.indexOf("async fn confirm("); + expect(start).toBeGreaterThan(-1); + const body = exit.slice(start, exit.indexOf("fn hide_windows(", start)); + expect(body).toContain("identity.pid == child_pid => Ownership::Ours"); + expect(body).toContain("Ok(_) => Ownership::Foreign"); + // Nothing listening is only proof the child is gone if the child said so. + expect(body).toContain("watch.exit().is_some()"); + expect(body).toContain("Ownership::Unknown"); + const drain = exit.slice(exit.indexOf("pub async fn drain_current(")); + const drainBody = drain.slice(0, drain.indexOf("\nenum Ownership")); + expect(drainBody).toContain("Ownership::Foreign => DrainVerdict::Drained"); + expect(drainBody).toContain("Ownership::Unknown => DrainVerdict::OwnershipUnknown"); + }); + + test("the startup sequence is what grants ownership, and only on a readable answer", () => { + const startup = code(STARTUP); + const bind = startup.slice(startup.indexOf("async fn bind(")); + const body = bind.slice(0, bind.indexOf("\n}")); + expect(body).toContain("proxy.identify()"); + expect(body).toContain("proxy.bind(identity)"); + expect(body).toContain("state.confirm_ownership(identity)"); + // An answer that cannot be read leaves the app owning nothing. + expect(body).toContain("_ => return None,"); + // And the sequence does not report Ready against an instance it could not identify: the + // management token is only ever sent to a bound one, so a dashboard there would not load. + const callers = startup.match(/bind\(app, &proxy, deadline\)\.await\.is_none\(\)/g) || []; + expect(callers).toHaveLength(2); + }); + + test("the app's own origin is allowed by both spellings, and nothing wider", () => { + const window = code(WINDOW); + const rule = window.slice(window.indexOf("fn is_app_origin(")); + const body = rule.slice(0, rule.indexOf("\n}")); + // The custom scheme everywhere, and the http spelling WebView2 needs on Windows. + expect(body).toContain('"tauri" => true'); + expect(body).toContain('url.host_str() == Some("tauri.localhost")'); + // Not https, which is not the scheme the pinned Tauri serves the app over, and not a port, + // which would mean something else is answering. + expect(body).toContain("url.port().is_none()"); + expect(body).not.toContain('"https"'); + expect(window).toContain("if is_app_origin(url) {"); + // Not localhost generally, and not a remote IPC widening. + expect(window).not.toContain('Some("localhost")'); + expect(readFileSync(repoPath("desktop/src-tauri/capabilities/default.json"), "utf8")).not.toContain( + "remote", + ); + }); +}); diff --git a/tests/clients/desktop-start-at-login-default.test.ts b/tests/clients/desktop-start-at-login-default.test.ts index abf3472b79c..f968fd97ae5 100644 --- a/tests/clients/desktop-start-at-login-default.test.ts +++ b/tests/clients/desktop-start-at-login-default.test.ts @@ -12,9 +12,12 @@ import { repoPath } from "../helpers/repo-root"; * the current state, an existing marker returns early, and the tray is built afterwards so its * checkbox reflects the result. Get the write order backwards and a user who turns the setting off * has it turned back on for them on the next launch. + * + * The one-time rewrite that teaches an existing login item to announce itself is the exception, and + * it writes its marker the other way round on purpose — see the comment on it. */ const FIRST_RUN = repoPath("desktop/src-tauri/src/first_run.rs"); -const LIB = repoPath("desktop/src-tauri/src/lib.rs"); +const STARTUP = repoPath("desktop/src-tauri/src/startup.rs"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); @@ -52,11 +55,24 @@ describe("start at login default", () => { }); test("it runs before the tray is installed", () => { - const lib = code(LIB); - const applied = lib.indexOf("first_run::apply_start_at_login_default"); - const tray = lib.indexOf("tray::install"); + const startup = code(STARTUP); + const applied = startup.indexOf("first_run::apply_start_at_login_default"); + const tray = startup.indexOf("crate::tray::install"); expect(applied).toBeGreaterThan(-1); expect(tray).toBeGreaterThan(-1); expect(applied).toBeLessThan(tray); }); + + test("the launch-origin rewrite claims its marker only once it has succeeded", () => { + const start = firstRun.indexOf("pub fn adopt_launch_origin_argument"); + expect(start).toBeGreaterThan(-1); + const body = firstRun.slice(start); + const enable = body.indexOf("autolaunch().enable()"); + const claim = body.indexOf("fs::write(&claimed"); + expect(enable).toBeGreaterThan(-1); + expect(claim).toBeGreaterThan(enable); + // It never turns the setting on or off; it only rewrites an entry that is already there. + expect(body).toContain("Ok(true) =>"); + expect(body).not.toContain("autolaunch().disable()"); + }); }); diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts new file mode 100644 index 00000000000..781416398c5 --- /dev/null +++ b/tests/clients/desktop-startup-surface.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The startup surface exists before the work it reports on. + * + * Discovery, the liveness probe, the sidecar spawn and the health wait all used to run inside + * `setup()`, and the window was created hidden afterwards. Every failure in that stretch was + * therefore invisible: the spawn event stream was destructured into `_events` and dropped, so the + * child's exit code went with it, and a run of probes that time out rather than refuse takes over a + * minute with nothing on screen. Ordering is the whole of the fix, and a state that reports work + * already finished elsewhere is not a state — it is a label. Both are read out of the source. + */ +const SRC = "desktop/src-tauri/src"; +const LIB = repoPath(`${SRC}/lib.rs`); +const SIDECAR = repoPath(`${SRC}/sidecar.rs`); +const STARTUP = repoPath(`${SRC}/startup.rs`); +const PROXY = repoPath(`${SRC}/proxy.rs`); +const PAGE = repoPath("desktop/ui/main.js"); +const CONFIG = repoPath("desktop/src-tauri/tauri.conf.json"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop startup surface", () => { + const lib = code(LIB); + const startup = code(STARTUP); + + test("the window is built and shown before the sequence that reports into it", () => { + const setup = lib.indexOf(".setup(|app|"); + expect(setup).toBeGreaterThan(-1); + const built = lib.indexOf("WebviewWindowBuilder::new", setup); + const shown = lib.indexOf("window::show(&window)", setup); + const begun = lib.indexOf("startup::begin(app.handle())", setup); + expect(built).toBeGreaterThan(-1); + expect(shown).toBeGreaterThan(built); + expect(begun).toBeGreaterThan(shown); + }); + + test("setup resolves nothing, registers nothing and starts nothing", () => { + const setup = lib.slice( + lib.indexOf(".setup(|app|"), + lib.indexOf(".build(tauri::generate_context!())"), + ); + expect(setup.length).toBeGreaterThan(0); + for (const call of [ + "block_on", + "ensure_proxy", + "resolve::run", + "ProxyClient::new", + "tray_availability::detect()", + "tray::install", + "first_run::", + "sidecar::", + ]) { + expect(setup).not.toContain(call); + } + }); + + test("resolving and registering are states that own their work", () => { + expect(startup).toContain("Phase::Resolving"); + // D5: the shell asks the bundled CLI rather than reading a port record and guessing. + expect(startup).toContain("resolve::run(app, deadline).await"); + expect(startup).toContain("ProxyClient::new(endpoint"); + expect(startup).toContain("Phase::Registering"); + expect(startup).toContain("tray_availability::detect"); + expect(startup).toContain("first_run::apply_start_at_login_default(app)"); + expect(startup).toContain("crate::tray::install(&handle)"); + }); + + test("the app's own surface is registered before the runtime is touched", () => { + const registering = startup.indexOf("Phase::Registering, None)"); + const resolving = startup.indexOf("Phase::Resolving, None)"); + const starting = startup.indexOf("Phase::Starting, None)"); + expect(registering).toBeGreaterThan(-1); + expect(resolving).toBeGreaterThan(registering); + expect(starting).toBeGreaterThan(resolving); + }); + + test("the spawn event stream is consumed rather than discarded", () => { + const sidecar = code(SIDECAR); + expect(sidecar).not.toContain("_events"); + expect(sidecar).toContain("let (events, child) = command.spawn()"); + expect(sidecar).toContain("watch.follow(events)"); + expect(sidecar).toContain("CommandEvent::Terminated(payload)"); + }); + + test("the child's exit code is what ends the wait early", () => { + const wait = startup.indexOf("Phase::Waiting, None);"); + expect(wait).toBeGreaterThan(-1); + const loop = startup.slice(wait, startup.indexOf("async fn register(", wait)); + expect(loop).toContain("watch.exit()"); + expect(loop).toContain("exit.describe()"); + }); + + test("one deadline covers the whole sequence and bounds every probe under it", () => { + expect(startup).toContain("pub const DEADLINE: Duration"); + expect(startup).toContain("let deadline = started + DEADLINE;"); + // The budget for finding an existing runtime is the CLI's now, not a second one here: the + // tuned probe budgets exist because a shell-side reimplementation answered "nobody is + // listening" twice and started duplicate proxies. + expect(startup).not.toContain("ATTACH_BUDGET"); + expect(startup).not.toContain("fn healthy_by"); + expect(startup).toContain("resolve::run(app, deadline).await"); + // A probe bounded only by the client's own timeout overruns whatever budget it was started + // under, which is how a stated ceiling becomes an unstated one. + expect(startup).not.toContain("proxy.is_alive()"); + expect(startup).toContain("proxy.alive_within(deadline)"); + // Registration waits on a session bus and on the main thread, and both can stall; neither is + // allowed to leave the page in a state whose retry could do nothing. + expect(startup).toContain("tokio::time::timeout_at(\n deadline,"); + expect(startup).toContain("tokio::time::timeout_at(deadline, receiver)"); + const proxy = code(PROXY); + expect(proxy).toContain("timeout_at(deadline, self.is_alive())"); + // The stop is the bundled CLI's now, under its own deadline. + const stop = code(repoPath("desktop/src-tauri/src/runtime_stop.rs")); + expect(stop).toContain("timeout_at(deadline, command.output())"); + expect(stop).toContain("pub const DEADLINE: Duration"); + }); + + test("a retry waits on the child it already started rather than starting a second one", () => { + expect(startup).toContain("&& watch.exit().is_none()"); + const guard = startup.indexOf("if owns_live_child {"); + const spawn = startup.indexOf("sidecar::start(app, endpoint, watch)"); + expect(guard).toBeGreaterThan(-1); + expect(spawn).toBeGreaterThan(guard); + }); + + test("the diagnostic names the state, the endpoint, the home and how the child ended", () => { + const start = startup.indexOf("pub fn diagnostic("); + expect(start).toBeGreaterThan(-1); + const body = startup.slice(start, startup.indexOf("fn report(", start)); + for (const field of [ + "state:", + "reason:", + "elapsed:", + "endpoint:", + "home:", + "runtime process:", + "runtime output", + ]) { + expect(body).toContain(field); + } + expect(body).toContain("exit.describe()"); + }); + + test("the snapshot carries the finished states, not just the current one", () => { + expect(startup).toContain("pub completed: Vec<&'static str>"); + expect(startup).toContain("pub failed_phase: Option<&'static str>"); + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain("progress.completed"); + expect(page).toContain("progress.failedPhase"); + }); + + test("the retry, the snapshot and the phase list are reachable from the page", () => { + const handler = lib.slice( + lib.indexOf("generate_handler!["), + lib.indexOf("])", lib.indexOf("generate_handler![")), + ); + for (const command of ["startup_snapshot", "startup_phases", "retry_startup"]) { + expect(lib).toContain(`fn ${command}(`); + expect(handler).toContain(command); + } + expect(JSON.parse(readFileSync(CONFIG, "utf8")).app.withGlobalTauri).toBe(true); + }); + + test("the page derives its phases instead of restating them", () => { + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain('invoke("startup_phases")'); + expect(page).toContain('invoke("startup_snapshot")'); + expect(page).toContain('invoke("retry_startup")'); + expect(page).toContain('listen("startup-phase"'); + for (const phase of ["resolving", "probing", "attaching", "starting", "waiting", "registering"]) { + expect(page).not.toContain(`"${phase}"`); + } + }); + + test("every call into the shell can fail without leaving the page blank", () => { + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain("function reportPageFailure"); + // Both entry points — the first load and the retry — have to catch, because either one + // failing silently leaves a window that says "Starting…" forever. + expect(page.match(/reportPageFailure\(/g) || []).toHaveLength(3); + const retry = page.slice(page.indexOf('retry.addEventListener')); + expect(retry.slice(0, 400)).toContain("catch"); + }); + + test("the page never reaches for a dialog the webview cannot draw", () => { + const page = readFileSync(PAGE, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n]*/g, ""); + // The call form, not the word: a method on a receiver or a property of that name is fine. + expect(page).not.toMatch(/(^|[^.\w$])(?:window\s*\.\s*)?(?:alert|confirm|prompt)\s*\(/); + expect(page).toContain("#diagnostic"); + expect(page).toContain("clipboard.writeText"); + }); +}); diff --git a/tests/clients/desktop-tray-availability.test.ts b/tests/clients/desktop-tray-availability.test.ts new file mode 100644 index 00000000000..d230f606edd --- /dev/null +++ b/tests/clients/desktop-tray-availability.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * INV-DESKTOP-02 — tray availability is an answer from the session, and where there is none the + * window is shown and closing it quits through the same graceful drain. + * + * The pinned Linux backend creates an AppIndicator and reports success without checking that + * anything will display it, so `TrayIconBuilder::build` returning `Ok` proves nothing. Nor does the + * watcher merely existing: a StatusNotifierWatcher with no host attached still accepts + * registrations and still draws nothing, which is why the question asked is the specification's own + * — is a host registered. On stock GNOME the answer is no, and the shell's macOS-shaped assumptions + * (a window created hidden, a close that always hides) then left a running process with no way back + * in. The probe and the branches it feeds are read out of the source because a hosted Linux runner + * has no graphical session to observe them in. + */ +const SRC = "desktop/src-tauri/src"; +const AVAILABILITY = repoPath(`${SRC}/tray_availability.rs`); +const LIB = repoPath(`${SRC}/lib.rs`); +const EXIT = repoPath(`${SRC}/exit.rs`); +const STARTUP = repoPath(`${SRC}/startup.rs`); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop tray availability", () => { + const availability = code(AVAILABILITY); + const startup = code(STARTUP); + + test("the probe asks whether a host is registered, not whether a watcher exists", () => { + expect(availability).toContain('"org.kde.StatusNotifierWatcher"'); + expect(availability).toContain('"IsStatusNotifierHostRegistered"'); + expect(availability).toContain("Connection::new_session()"); + expect(availability).toContain('#[cfg(target_os = "linux")]'); + // A name that merely has an owner is the weaker question this replaces. + expect(availability).not.toContain("NameHasOwner"); + }); + + test("an unanswerable probe is read the same way as a watcher with no host", () => { + const start = availability.indexOf("pub fn from_host_registered"); + expect(start).toBeGreaterThan(-1); + const body = availability.slice(start, availability.indexOf("\n}", start)); + expect(body).toContain("Some(true) => TrayAvailability::Available"); + expect(body).toContain("Some(false) | None => TrayAvailability::Unavailable"); + }); + + test("nothing is assumed on the platform that needs the probe", () => { + const start = availability.indexOf("pub fn assumed()"); + expect(start).toBeGreaterThan(-1); + const body = availability.slice(start, availability.indexOf("\n }", start)); + expect(body).toContain('cfg!(target_os = "linux")'); + expect(body).toContain("Self::Unavailable"); + // The window can be closed before the probe answers, so the coordinator starts from the + // platform assumption rather than from optimism. + expect(code(EXIT)).toContain("TrayAvailability::assumed().hides_to_tray()"); + }); + + test("the verdict reaches the coordinator that decides what a close means", () => { + expect(startup).toContain("coordinator.set_tray(verdict)"); + expect(code(EXIT)).toContain("pub fn set_tray(&self, tray: TrayAvailability)"); + expect(code(repoPath(`${SRC}/window.rs`))).toContain("exit::gesture("); + expect(code(EXIT)).toContain("decide(inner.phase, inner.reason, inner.hides_to_tray)"); + }); + + test("a tray is only claimed once an icon exists to claim", () => { + // The verdict is published after the install, not before it: announcing a tray and then + // failing to build one would hide the window into nothing. + const verdict = startup.indexOf("let verdict = if tray.is_available() && install_tray(app, deadline).await"); + const published = startup.indexOf("coordinator.set_tray(verdict)"); + expect(verdict).toBeGreaterThan(-1); + expect(published).toBeGreaterThan(verdict); + expect(startup.slice(verdict, published)).toContain("TrayAvailability::Unavailable"); + }); + + test("a retry does not register a second tray", () => { + const register = startup.slice(startup.indexOf("async fn register("), startup.indexOf("async fn install_tray(")); + expect(register).toContain("startup.registration()"); + expect(register.indexOf("return done;")).toBeLessThan(register.indexOf("install_tray(app, deadline)")); + expect(register).toContain("startup.remember_registration(registration.clone())"); + }); + + test("tray availability decides the launch, not the origin of the launch", () => { + const start = startup.indexOf("pub fn shows_window"); + expect(start).toBeGreaterThan(-1); + const body = startup.slice(start, startup.indexOf("\n}", start)); + expect(body).toContain("!tray.is_available() || origin == LaunchOrigin::User"); + // The window is shown from inside the sequence, once the verdict is in, so a login launch on a + // session with no tray is not left hidden with nothing to reopen it from. + expect(startup).toContain("if shows_window(LaunchOrigin::detect(), verdict)"); + expect(code(LIB)).toContain("startup::LaunchOrigin::detect() == startup::LaunchOrigin::User"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a257ad0551d..a7a7fcee72f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -551,9 +551,15 @@ "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", + "desktop-exit-ownership.test.ts": "clients", + "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-startup-surface.test.ts": "clients", + "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-runtime-identity.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", From 600e323ccdfd18abf0c0179267ab3454be1d7135 Mon Sep 17 00:00:00 2001 From: yuanyuanlove Date: Mon, 21 Sep 2026 13:36:36 +0800 Subject: [PATCH 76/87] =?UTF-8?q?fix(kimi):=20K2.8=20registry=20refresh=20?= =?UTF-8?q?=E2=80=94=20adjustable=20thinking,=201M=20context,=20kimi-for-c?= =?UTF-8?q?oding=20default=20(#5403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release: v2.33.0-preview.20260825 * release: v2.34.0-preview.20260827 * release: v2.36.0-preview.20260829 * fix(release): pass the bump job's permissions through the reusable-workflow call (#3262) Both v2.40.0 release dispatches (33615174183 preview, 33615177849 main) died at startup_failure: a workflow_call cannot grant its callee more than the calling job holds, and dev-version-bump.yml's job declares contents+pull- requests write. #3129 wired the call but never dispatched a release, so this is its first live run. The caller job now declares exactly the callee's two permissions; no other job in release.yml gains anything. Co-authored-by: jun (cherry picked from commit 7ce0ba51834740d7b4d5ec4793f6572d84624409) * release: set preview channel version 2.48.0-preview.20260908 * release: set main channel version 2.48.0 * chore(release): promote 2.55.0-preview.20260914 to preview Promotes the dev product snapshot 62f02223a0 to the preview train. The 2.55.0 line carries the #4546 cost-guard work: one send budget per logical request with a shared final-recovery reserve, zero-is-zero refusals with a typed error rather than a synthetic 502, compact and the Kiro inner retries admitted against that budget, a finite send ceiling per root workflow with an interactive reserve a fan-out cannot take, and a healthy detour promoted on transient-hold expiry instead of released cold. The previous preview tip 2.54.0-preview.20260914 is already tagged and published and is outranked by v2.54.0, so it could not be re-released; this is a new candidate rather than a re-cut. * chore(release): promote the verified 2.55.0 product tree to main Same product tree as preview 7bdd1b29b5 / 2.55.0-preview.20260914, which published successfully with its registry smoke green. Only package.json version differs. * fix(kimi): update Kimi coding registry for K2.8 (adjustable thinking, 1M context, current alias default) - kimi-for-coding is the stable subscription alias Moonshot re-points at each coding release; it now routes to K2.8 Preview. Live GET /coding/v1/models lists only kimi-for-coding[-highspeed], k3, k3-256k; the k2.x ids are retired from the subscription endpoint. - K2.8 accepts the same adjustable low/high/max thinking ladder as k3 (verified live: 350K-token request accepted at max effort; upstream rejects beyond 1,048,576 with 'model token limit: 1048576'). - Bump kimi-for-coding context window to the verified 1M ceiling and advertise text+image input. - Default kimi / kimi-code presets to kimi-for-coding instead of the retired kimi-k2.7-code. - Update provider-registry parity test to match the new verified shape. * fix(kimi): retire k2.x ids from the coding picker and migrate saved configs to kimi-for-coding Address review on #5403: - MODEL_RENAMES gains kimi/kimi-code entries mapping the retired default kimi-k2.7-code to the live kimi-for-coding alias, so saved configs keep working after Moonshot removed the k2.x ids from the subscription endpoint. - The kimi/kimi-code presets seed only ids the endpoint still serves (live /coding/v1/models: kimi-for-coding, k3). Every preset metadata list is live-id only: seeding a retired id there re-armed the rename migration on every boot (#5066 shape), because the residue guard cannot skip a list that holds the retired id without the live alias. - KIMI_CODING_MODELS is replaced by KIMI_CODING_LIVE_MODELS built from KIMI_CODING_K3_MODELS + KIMI_CODING_K28_MODELS, so a future alias added to the K28 constant flows into the picker and every parallel record. - Parity tests now assert defaultModel is kimi-for-coding for both presets (a registry rollback to the retired default would otherwise pass silently). Verified: bun test on model-rename-migration, provider-registry-parity and codex-catalog (432 pass), full tests/providers sweep (only pre-existing proxy-environment timeouts fail, identical on the clean base), tsc clean. * docs(kimi): document the K2.8 coding refresh in the providers guide Address the CodeRabbit finding on #5403: the kimi row in the canonical English providers guide (and the zh-cn translation) now documents the kimi-for-coding default, the 1M context window, the adjustable low/high/max ladder (default max), image input, and the automatic kimi-k2.7-code migration on upgrade. Verified with the required validation: cd docs-site && bun install --frozen-lockfile && bun run build (497 pages, exit 0). * fix(kimi): repair saved K2 coding metadata * fix(kimi): drop the stale no-reasoning classification even when only the replacement id is present Address the CodeRabbit finding on the maintainer's 608d7a22b: a saved row can carry kimi-for-coding in noReasoningModels while every retired id is already gone from the row (the pre-K2.8 registry seeded the alias there). The early return in dropRenamedIdsFromList required the retired id, so the stale classification survived and kept the reasoning picker disabled for the live alias. Proceed when the list contains either id and filter both. Verified: model-rename-migration + provider-registry-parity 101 pass, tsc clean; new regression test covers the replacement-id-only row. * fix(kimi): preserve explicit reasoning overrides --------- Co-authored-by: JUN Co-authored-by: jun Co-authored-by: jun Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: t Co-authored-by: JUN Co-authored-by: panyuanyuan --- .../src/content/docs/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- src/providers/model-rename-migration.ts | 112 +++++++++++++- src/providers/registry/entries-core.ts | 13 +- src/providers/registry/entries-extended.ts | 10 +- src/providers/registry/model-seeds.ts | 35 +++-- .../providers/model-rename-migration.test.ts | 146 ++++++++++++++++++ .../provider-registry-parity.test.ts | 36 +++-- 8 files changed, 323 insertions(+), 33 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b013cecfd2e..e8cba6c928d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -198,7 +198,7 @@ ocx logout | --- | --- | --- | --- | | `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth uses the separate Grok CLI subscription gateway. The API-key override uses `https://api.x.ai/v1` and may inject Priority Processing. Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | -| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | +| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. Retired `kimi-k2.x` selections are migrated to the alias on upgrade. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index f4796581124..d799f306705 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -101,7 +101,7 @@ ocx logout | --- | --- | --- | --- | | `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth 使用独立的 Grok CLI 订阅网关。API 密钥覆盖模式使用 `https://api.x.ai/v1`,并可能注入 Priority Processing。优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | -| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | +| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan 编程模型。默认使用稳定的 `kimi-for-coding` 别名(当前指向 K2.8 Preview):100 万 token 上下文、可调 `low`/`high`/`max` 思考档(默认 `max`)、支持文本 + 图片输入。已下架的 `kimi-k2.x` 选择会在升级时自动迁移到该别名。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | diff --git a/src/providers/model-rename-migration.ts b/src/providers/model-rename-migration.ts index 0606fe3dcd9..0c66e803de3 100644 --- a/src/providers/model-rename-migration.ts +++ b/src/providers/model-rename-migration.ts @@ -45,6 +45,52 @@ export interface ModelRename { * but lose the reasoning picker entirely. */ dropReasoningEffortMap?: boolean; + /** + * Remove both the retired id and its replacement from `noReasoningModels`. + * + * Use this only when the rename also marks a capability change: carrying the old + * no-reasoning classification onto a newly adjustable alias would keep the picker + * disabled after the model id itself was repaired. + */ + dropNoReasoningModels?: boolean; + /** Refresh exact registry defaults already saved under the replacement id. */ + targetSeedRefresh?: { + contextWindow?: { from: number; to: number }; + reasoning?: { + fromEfforts: readonly string[]; + toEfforts: readonly string[]; + defaultEffort: string; + effortMap: Readonly>; + }; + }; +} + +const KIMI_K28_ALIAS = "kimi-for-coding"; +const KIMI_RETIRED_CODING_IDS = [ + "kimi-k2.7-code", + "kimi-k2.7-code-highspeed", + "kimi-k2.6", + "kimi-k2.5", +] as const; +const KIMI_K28_TARGET_SEED_REFRESH: NonNullable = { + contextWindow: { from: 262_144, to: 1_048_576 }, + reasoning: { + fromEfforts: [], + toEfforts: ["low", "high", "max"], + defaultEffort: "max", + effortMap: { none: "none", low: "low", medium: "high", high: "high", xhigh: "max", max: "max" }, + }, +}; + +function kimiCodingRenames(provider: "kimi" | "kimi-code", endpoint: string): ModelRename[] { + return KIMI_RETIRED_CODING_IDS.map(from => ({ + provider, + from, + to: KIMI_K28_ALIAS, + reason: `Moonshot retired the k2.x coding ids from the ${endpoint}; kimi-for-coding is the stable alias the endpoint still serves`, + dropNoReasoningModels: true, + targetSeedRefresh: KIMI_K28_TARGET_SEED_REFRESH, + })); } /** @@ -65,6 +111,14 @@ export const MODEL_RENAMES: readonly ModelRename[] = [ to: "qwen3.8-max", reason: "Alibaba shipped Qwen3.8-Max as stable and documents the preview endpoint as liable to be taken offline once preview concludes", }, + // Kimi coding renames. Moonshot retired the k2.x ids from the subscription/coding + // endpoint when K2.8 Preview shipped (live /coding/v1/models lists only + // kimi-for-coding[-highspeed], k3, k3-256k); kimi-for-coding is the stable alias the + // endpoint still serves and currently routes to K2.8 Preview. The registry picker no + // longer seeds the retired ids, so a saved defaultModel naming one is a dead selection + // rather than a merely outdated one. + ...kimiCodingRenames("kimi", "subscription endpoint after K2.8 Preview shipped"), + ...kimiCodingRenames("kimi-code", "coding endpoint after K2.8 Preview shipped"), // Antigravity Flash generations. Google takes the previous Flash model off Cloud Code // Assist almost immediately when the next ships, so a saved 3.6 (or older 3.5) id is a // dead selection rather than a merely outdated one. Routing already redirects these ids @@ -133,6 +187,18 @@ function renameInList(value: unknown, from: string, to: string): string[] | null return next; } +function dropRenamedIdsFromList( + value: unknown, + from: string, + to: string, + dropStaleTarget: boolean, +): string[] | null { + if (!Array.isArray(value)) return null; + const hasRetired = value.includes(from); + if (!hasRetired && !(dropStaleTarget && value.includes(to))) return null; + return value.filter(entry => typeof entry === "string" && entry !== from && entry !== to); +} + function renameInRecord(value: unknown, from: string, to: string): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; @@ -160,6 +226,42 @@ function dropFromRecord(value: unknown, from: string): Record | return next; } +function sameStringArray(value: unknown, expected: readonly string[]): boolean { + return Array.isArray(value) + && value.length === expected.length + && value.every((entry, index) => entry === expected[index]); +} + +function targetReasoningMatchesStaleSeed(row: Record, rename: ModelRename): boolean { + const reasoning = rename.targetSeedRefresh?.reasoning; + const efforts = row.modelReasoningEfforts as Record | undefined; + return !!reasoning && !!efforts && sameStringArray(efforts[rename.to], reasoning.fromEfforts); +} + +/** Refresh only exact defaults emitted by the previous registry; preserve user overrides. */ +function refreshTargetSeed(row: Record, rename: ModelRename): boolean { + const refresh = rename.targetSeedRefresh; + if (!refresh) return false; + let changed = false; + + const windows = row.modelContextWindows as Record | undefined; + if (windows && refresh.contextWindow && windows[rename.to] === refresh.contextWindow.from) { + windows[rename.to] = refresh.contextWindow.to; + changed = true; + } + + const reasoning = refresh.reasoning; + const efforts = row.modelReasoningEfforts as Record | undefined; + if (!reasoning || !efforts || !sameStringArray(efforts[rename.to], reasoning.fromEfforts)) return changed; + efforts[rename.to] = [...reasoning.toEfforts]; + + const defaults = (row.modelDefaultReasoningEfforts ??= {}) as Record; + if (!(rename.to in defaults)) defaults[rename.to] = reasoning.defaultEffort; + const maps = (row.modelReasoningEffortMap ??= {}) as Record; + if (!(rename.to in maps)) maps[rename.to] = { ...reasoning.effortMap }; + return true; +} + /** * `provider/model` rows in the top-level `disabledModels` list. * @@ -281,7 +383,14 @@ export function projectModelRenames( let touched = false; for (const field of MODEL_ID_LISTS) { if (isRegistryResidue(seed, field, row[field], rename)) continue; - const next = renameInList(row[field], rename.from, rename.to); + const next = rename.dropNoReasoningModels && field === "noReasoningModels" + ? dropRenamedIdsFromList( + row[field], + rename.from, + rename.to, + targetReasoningMatchesStaleSeed(row, rename), + ) + : renameInList(row[field], rename.from, rename.to); if (!next) continue; row[field] = next; touched = true; @@ -300,6 +409,7 @@ export function projectModelRenames( touched = true; } if (renameDisabledModels(config, rename)) touched = true; + if (touched && refreshTargetSeed(row, rename)) touched = true; if (touched) { changed = true; diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index f16f9f15687..3a956081ddc 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -55,7 +55,7 @@ import { deepseekThinkingEffortsFor, deepseekReasoningMapFor, KIMI_K3_STANDARD_CONTEXT_WINDOW, - KIMI_CODING_MODELS, + KIMI_CODING_LIVE_MODELS, KIMI_THINKING_MODELS, KIMI_CODING_NO_REASONING_MODELS, KIMI_CODING_K3_REASONING_EFFORTS, @@ -444,8 +444,14 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ oauthId: "kimi", jawcodeBundle: "moonshot", note: "Log in with your Kimi account", - models: KIMI_CODING_MODELS, - defaultModel: "kimi-k2.7-code", + // 260921: the retired k2.x ids stay out of the picker — live /coding/v1/models lists + // only kimi-for-coding[-highspeed], k3, k3-256k. Saved rows still naming kimi-k2.7-code + // are repaired by MODEL_RENAMES in model-rename-migration.ts. + models: KIMI_CODING_LIVE_MODELS, + // 260921: kimi-k2.7-code was retired from the subscription endpoint (live /models lists + // only kimi-for-coding[-highspeed], k3, k3-256k). The kimi-for-coding alias is the + // stable ID and currently routes to K2.8 Preview. + defaultModel: "kimi-for-coding", modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, // K3 accepts low/high/max; Codex aliases are normalized by the model-scoped wire map. @@ -1258,4 +1264,3 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ note: "Serverless Inference subscription API. Live discovery exposes only kimi-k2-instruct because Vultr documents it as the sole tool-calling model.", }, ]; - diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 48ebd4242d7..3dc0ff10b46 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -72,10 +72,10 @@ import { VOLCENGINE_PLAN_TEXT_ONLY_MODELS, ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES, KIMI_API_MODELS, - KIMI_CODING_MODELS, KIMI_THINKING_MODELS, KIMI_CODING_NO_REASONING_MODELS, KIMI_API_NO_REASONING_MODELS, + KIMI_CODING_LIVE_MODELS, KIMI_CODING_REASONING_EFFORTS, KIMI_CODING_DEFAULT_REASONING_EFFORTS, KIMI_CODING_REASONING_EFFORT_MAPS, @@ -1013,13 +1013,17 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ }, { id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code", + // 260921: kimi-k2.7-code was retired from the coding endpoint; the kimi-for-coding alias + // is the stable ID and currently routes to K2.8 Preview (same as the OAuth preset). + dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-for-coding", modelSuffixBracketStrip: true, // API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth. promptCacheKey: true, // Keep Responses tool-result adjacency aligned with the OAuth preset (#4726). requiresAdjacentResponsesToolResults: true, - models: KIMI_CODING_MODELS, + // 260921: same live-id picker as the OAuth preset — the retired k2.x ids are repaired + // in saved configs by MODEL_RENAMES, not offered on fresh installs. + models: KIMI_CODING_LIVE_MODELS, modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, noReasoningModels: KIMI_CODING_NO_REASONING_MODELS, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index c3ee348b6ee..1d6884aabfb 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -642,11 +642,26 @@ export const ALIBABA_TOKEN_PLAN_PRESERVE_REASONING = [ export const KIMI_K3_STANDARD_CONTEXT_WINDOW = 262_144; export const KIMI_K3_1M_CONTEXT_WINDOW = 1_048_576; export const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]"]; +// 260921 Kimi K2.8: `kimi-for-coding` is the stable subscription alias Moonshot re-points +// at each coding release. Live GET /coding/v1/models lists only kimi-for-coding[-highspeed], +// k3, k3-256k — the k2.x ids are retired from the subscription endpoint. Since K2.8 Preview +// the alias serves an adjustable low/high/max thinking ladder (same wire map as k3) and a +// 1M context ceiling. Verified live 260921: 350K-token request accepted; upstream rejects +// with "model token limit: 1048576" beyond that. +// Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html +export const KIMI_CODING_K28_MODELS = ["kimi-for-coding"]; +export const KIMI_CODING_LIVE_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_CODING_K28_MODELS]; +export const KIMI_CODING_ADJUSTABLE_THINKING_MODELS = [...KIMI_CODING_LIVE_MODELS]; export const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"]; export const KIMI_API_MODELS = ["kimi-k3", ...KIMI_LEGACY_API_MODELS]; -export const KIMI_CODING_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_LEGACY_API_MODELS, "kimi-for-coding"]; -export const KIMI_THINKING_MODELS = KIMI_CODING_MODELS; -export const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_MODELS.filter(id => !KIMI_CODING_K3_MODELS.includes(id)); +// Every kimi coding preset record - picker, context windows, locked-parameter lists - +// derives from the live ids only. seeding a retired id in a metadata list would re-arm +// the model-rename migration on every boot (#5066): the list holds the retired id but +// not the live alias, so the residue guard cannot skip it. The retired ids survive only +// in KIMI_LEGACY_API_MODELS (moonshot platform API records); model-rename-migration +// repairs saved rows still naming them. +export const KIMI_THINKING_MODELS = KIMI_CODING_LIVE_MODELS; +export const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_LIVE_MODELS.filter(id => !KIMI_CODING_ADJUSTABLE_THINKING_MODELS.includes(id)); export const KIMI_API_NO_REASONING_MODELS = KIMI_API_MODELS.filter(id => id !== "kimi-k3"); export const KIMI_CODING_K3_REASONING_EFFORTS = ["low", "high", "max"]; export const KIMI_CODING_K3_REASONING_EFFORT_MAP: Record = { @@ -658,19 +673,19 @@ export const KIMI_CODING_K3_REASONING_EFFORT_MAP: Record = { max: "max", }; export const KIMI_CODING_REASONING_EFFORTS = Object.fromEntries( - KIMI_CODING_MODELS.map(id => [id, KIMI_CODING_K3_MODELS.includes(id) ? KIMI_CODING_K3_REASONING_EFFORTS : []]), + KIMI_CODING_LIVE_MODELS.map(id => [id, KIMI_CODING_ADJUSTABLE_THINKING_MODELS.includes(id) ? KIMI_CODING_K3_REASONING_EFFORTS : []]), ); export const KIMI_CODING_DEFAULT_REASONING_EFFORTS = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, "max"]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, "max"]), ); export const KIMI_CODING_REASONING_EFFORT_MAPS = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, KIMI_CODING_K3_REASONING_EFFORT_MAP]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, KIMI_CODING_K3_REASONING_EFFORT_MAP]), ); export const KIMI_API_REASONING_EFFORTS = Object.fromEntries( KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? ["max"] : []]), ); -export const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_MODELS; -export const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-for-coding"]; +export const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_LIVE_MODELS; +export const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-for-coding"]; export const KIMI_API_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? KIMI_K3_1M_CONTEXT_WINDOW : 262_144]), ); @@ -758,10 +773,10 @@ export const NVIDIA_NIM_NO_VISION_MODELS = [ "poolside/laguna-xs-2.1", "z-ai/glm-5.3", "z-ai/glm-5.2", ]; export const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - KIMI_CODING_MODELS.map(id => [id, id === "k3[1m]" ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), + KIMI_CODING_LIVE_MODELS.map(id => [id, (id === "k3[1m]" || KIMI_CODING_K28_MODELS.includes(id)) ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), ); export const KIMI_CODING_MODEL_INPUT_MODALITIES = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, ["text", "image"]]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, ["text", "image"]]), ); export const NEURALWATT_REASONING_HISTORY_MODELS = [ "glm-5.3", "glm-5.3-short", "glm-5.3-flash", diff --git a/tests/providers/model-rename-migration.test.ts b/tests/providers/model-rename-migration.test.ts index be52a3da6ab..63f3ff71b69 100644 --- a/tests/providers/model-rename-migration.test.ts +++ b/tests/providers/model-rename-migration.test.ts @@ -142,6 +142,152 @@ describe("registry model rename migration (#1610)", () => { expect(entry?.models).not.toContain(rename.from); } }); + + test("repairs a saved kimi row still defaulting to the retired k2.7 id", () => { + // The shape a config saved under the pre-K2.8 registry carries: the picker list, + // the context-window record and the default all name kimi-k2.7-code, and the old + // registry already seeded kimi-for-coding rows next to them. + const stale = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + defaultModel: "kimi-k2.7-code", + models: ["k3", "k3[1m]", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", "kimi-for-coding"], + modelContextWindows: { "kimi-k2.7-code": 262_144, "kimi-for-coding": 262_144 }, + noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", "kimi-for-coding"], + modelReasoningEfforts: { "kimi-k2.7-code": [], "kimi-for-coding": [] }, + modelDefaultReasoningEfforts: { k3: "max" }, + modelReasoningEffortMap: { k3: { high: "high" } }, + }, + }, + } as unknown as OcxConfig; + + const { config, changed } = projectModelRenames(stale, MODEL_RENAMES); + const prov = config.providers.kimi!; + expect(changed).toBe(true); + expect(prov.defaultModel).toBe("kimi-for-coding"); + expect(prov.models).toEqual(["k3", "k3[1m]", "kimi-for-coding"]); + expect(prov.modelContextWindows).toEqual({ "kimi-for-coding": 1_048_576 }); + expect(prov.noReasoningModels).toEqual([]); + expect(prov.modelReasoningEfforts).toEqual({ "kimi-for-coding": ["low", "high", "max"] }); + expect(prov.modelDefaultReasoningEfforts).toEqual({ k3: "max", "kimi-for-coding": "max" }); + expect(prov.modelReasoningEffortMap?.["kimi-for-coding"]).toEqual({ + none: "none", low: "low", medium: "high", high: "high", xhigh: "max", max: "max", + }); + }); + + test("repairs the kimi-code key preset row the same way", () => { + const stale = { + providers: { + "kimi-code": { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "key", + apiKey: "sk-test", + defaultModel: "kimi-k2.7-code", + models: ["k3", "k3[1m]", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "kimi-for-coding"], + }, + }, + } as unknown as OcxConfig; + + const { config, changed } = projectModelRenames(stale, MODEL_RENAMES); + const prov = config.providers["kimi-code"]!; + expect(changed).toBe(true); + expect(prov.defaultModel).toBe("kimi-for-coding"); + expect(prov.models).toEqual(["k3", "k3[1m]", "kimi-for-coding"]); + }); + + test("preserves explicit kimi-for-coding metadata while retiring old ids", () => { + const stale = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + defaultModel: "kimi-k2.6", + models: ["kimi-k2.6", "kimi-for-coding"], + modelContextWindows: { "kimi-k2.6": 262_144, "kimi-for-coding": 131_072 }, + modelReasoningEfforts: { "kimi-k2.6": [], "kimi-for-coding": ["low"] }, + modelDefaultReasoningEfforts: { "kimi-for-coding": "low" }, + modelReasoningEffortMap: { "kimi-for-coding": { medium: "low" } }, + }, + }, + } as unknown as OcxConfig; + + const { config } = projectModelRenames(stale, MODEL_RENAMES); + const prov = config.providers.kimi!; + expect(prov.defaultModel).toBe("kimi-for-coding"); + expect(prov.models).toEqual(["kimi-for-coding"]); + expect(prov.modelContextWindows?.["kimi-for-coding"]).toBe(131_072); + expect(prov.modelReasoningEfforts?.["kimi-for-coding"]).toEqual(["low"]); + expect(prov.modelDefaultReasoningEfforts?.["kimi-for-coding"]).toBe("low"); + expect(prov.modelReasoningEffortMap?.["kimi-for-coding"]).toEqual({ medium: "low" }); + }); + + test("clears a stale no-reasoning classification saved under the live alias alone", () => { + // A config saved by the pre-K2.8 registry can carry kimi-for-coding in + // noReasoningModels even after every retired id is gone from the row - the old + // registry seeded the alias there. With no `from` left to rename, the stale + // classification would survive and keep the picker disabled; the drop must + // therefore trigger on the replacement id alone. + const stale = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + defaultModel: "kimi-for-coding", + models: ["k3", "k3[1m]", "kimi-for-coding"], + noReasoningModels: ["kimi-for-coding"], + modelReasoningEfforts: { "kimi-for-coding": [] }, + }, + }, + } as unknown as OcxConfig; + + const { config, changed } = projectModelRenames(stale, MODEL_RENAMES); + const prov = config.providers.kimi!; + expect(changed).toBe(true); + expect(prov.noReasoningModels).toEqual([]); + expect(prov.modelReasoningEfforts?.["kimi-for-coding"]).toEqual(["low", "high", "max"]); + }); + + test("preserves an explicit no-reasoning override on the live alias", () => { + const configured = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + defaultModel: "kimi-for-coding", + models: ["k3", "k3[1m]", "kimi-for-coding"], + noReasoningModels: ["kimi-for-coding"], + modelReasoningEfforts: { "kimi-for-coding": ["low"] }, + }, + }, + } as unknown as OcxConfig; + + const { config, changed } = projectModelRenames(configured, MODEL_RENAMES); + expect(changed).toBe(false); + expect(config.providers.kimi?.noReasoningModels).toEqual(["kimi-for-coding"]); + }); + + test("leaves a kimi row repointed at a different gateway alone", () => { + const custom = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://my-proxy.internal/v1", + authMode: "oauth", + defaultModel: "kimi-k2.7-code", + models: ["kimi-k2.7-code"], + }, + }, + } as unknown as OcxConfig; + const { changed } = projectModelRenames(custom, MODEL_RENAMES); + expect(changed).toBe(false); + }); }); describe("model rename startup persistence", () => { diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 65f0cd0cc7f..cf4d5b3bd78 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -945,15 +945,12 @@ describe("provider registry parity", () => { }); test("Kimi coding aliases preserve model context and capability parity", () => { - const codingModels = [ - "k3", - "k3[1m]", - "kimi-k2.7-code", - "kimi-k2.7-code-highspeed", - "kimi-k2.6", - "kimi-k2.5", - "kimi-for-coding", - ]; + // 260921: the picker AND every preset metadata list seed only ids the subscription + // endpoint still serves (live /coding/v1/models: kimi-for-coding[-highspeed], k3, + // k3-256k). Seeding a retired id in a metadata list would re-arm the model-rename + // migration on every boot (#5066); saved rows still naming one are repaired by + // MODEL_RENAMES instead. + const codingModels = ["k3", "k3[1m]", "kimi-for-coding"]; const parityLists = [ "noReasoningModels", "noTemperatureModels", @@ -966,13 +963,25 @@ describe("provider registry parity", () => { for (const providerId of ["kimi", "kimi-code"]) { const entry = PROVIDER_REGISTRY.find(provider => provider.id === providerId); expect(entry?.models).toEqual(codingModels); + // The whole point of the refresh: both presets default to the live alias. A + // registry rollback to the retired default would silently pass without this. + expect(entry?.defaultModel).toBe("kimi-for-coding"); + expect(entry?.models).not.toContain("kimi-k2.7-code"); for (const modelId of codingModels) { - expect(entry?.modelContextWindows?.[modelId]).toBe(modelId === "k3[1m]" ? 1_048_576 : 262_144); + // 260921: kimi-for-coding (K2.8 Preview) shares the verified 1M ceiling with k3[1m]; + // all other ids stay at the 256K standard window. + expect(entry?.modelContextWindows?.[modelId]).toBe(modelId === "k3[1m]" || modelId === "kimi-for-coding" ? 1_048_576 : 262_144); } for (const field of parityLists) { - expect(entry?.[field]).toContain("kimi-k2.7-code"); - expect(entry?.[field]).toContain("kimi-for-coding"); + // Every preset list is live-id only: kimi-for-coding must be there, the retired + // k2.x ids must not (a stale k2.7 row would leak the dead id back into the picker). + if (field !== "noReasoningModels") expect(entry?.[field]).toContain("kimi-for-coding"); + expect(entry?.[field] ?? []).not.toContain("kimi-k2.7-code"); } + // kimi-for-coding left noReasoningModels when K2.8 added the adjustable ladder. + expect(entry?.noReasoningModels ?? []).not.toContain("kimi-for-coding"); + expect(entry?.modelReasoningEfforts?.["kimi-for-coding"]).toEqual(["low", "high", "max"]); + expect(entry?.modelDefaultReasoningEfforts?.["kimi-for-coding"]).toBe("max"); expect(entry?.modelSuffixBracketStrip).toBe(true); expect(entry?.promptCacheKey).toBe(true); // Key-pool 429 rotation rebuilds the provider from the persisted config (not the routed @@ -1004,7 +1013,8 @@ describe("provider registry parity", () => { expect(entry?.noPenaltyModels).toContain("k3"); expect(entry?.preserveReasoningContentModels).toContain("k3"); expect(entry?.preserveReasoningContentModels).toContain("k3[1m]"); - expect(entry?.modelReasoningEfforts?.["kimi-for-coding"]).toEqual([]); + // 260921: K2.8 gave kimi-for-coding the same adjustable low/high/max ladder as k3. + expect(entry?.modelReasoningEfforts?.["kimi-for-coding"]).toEqual(["low", "high", "max"]); } const kimi = PROVIDER_REGISTRY.find(provider => provider.id === "kimi")!; From 372642757cef5b8578d29280a10588855a6fd1bc Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:19:54 +0900 Subject: [PATCH 77/87] docs: fold the desktop app into a beta section and record install locations (#5417) * docs: fold the desktop app into a beta section and record install locations * docs: resync translated READMEs for desktop beta --- AGENTS_INSTALL.md | 64 +++++++++++++++++++++++++++++++++++++++ README.md | 35 +++++++++++++-------- readme/README.fr.md | 40 ++++++++++++++++-------- readme/README.ja.md | 51 +++++++++++++++++++------------ readme/README.ko.md | 42 ++++++++++++++++--------- readme/README.ru.md | 44 +++++++++++++++++---------- readme/README.tr.md | 48 ++++++++++++++++++----------- readme/README.zh-CN.md | 41 ++++++++++++++++--------- readme/README.zh-TW.md | 40 +++++++++++++++--------- readme/i18n-manifest.json | 14 ++++----- 10 files changed, 291 insertions(+), 128 deletions(-) diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 05278f37cb9..50d5f8554e0 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -52,6 +52,70 @@ The following instructions concern **starring the repository on GitHub**. the matter permanently — do not argue it, re-frame it, or raise it again later. +## Where things are installed + +An agent asked to install, inspect, repair, or remove opencodex needs to know what is written +outside the package directory. Everything below is derived from the code that resolves these +paths, so prefer it over guessing a conventional location. + +### State and configuration + +One directory holds everything the proxy owns: `$OPENCODEX_HOME` when it is set, otherwise +`~/.opencodex`. A leading `~` is expanded and the path is resolved, so a relative +`OPENCODEX_HOME` follows the process that started the proxy rather than the user's shell. + +| Path | What it is | +|---|---| +| `/config.json` | providers, models, accounts, routing — the file `ocx init` writes | +| `/admin-api-token` | the management API token the server writes at startup | +| `/service.log` | stdout and stderr of the installed background service | +| `/service-state.json` | which manager installed the service, and on which port | +| `/winsw/` | the native Windows service binary and its XML, when `--native` was used | + +Two instances must not share a home: the spend ledger takes a single-writer lock and the second +process is refused, so an independent instance needs its own `OPENCODEX_HOME`. + +### Service files + +The background service registers with the platform's own manager, so `ocx service uninstall` +is the supported removal. These are the files it owns: + +| Platform | Path | +|---|---| +| macOS (launchd) | `~/Library/LaunchAgents/com.opencodex.proxy.plist` | +| Linux (systemd user unit) | `~/.config/systemd/user/opencodex-proxy.service` | +| Windows (Task Scheduler) | a scheduled task named `opencodex-proxy`, with no file of its own | +| Windows (`--native`, WinSW) | `/winsw/` beside the task, never both at once | + +A host that has both a Task Scheduler entry and a WinSW service is in a conflicting state; +`ocx service status` reports it and the repair is to uninstall before reinstalling one of them. + +### The CLI + +`npm install -g @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the +Bun runtime it needs is bundled inside that package. There is no separate runtime to install and +no WSL layer on Windows. + +### The desktop app (beta) + +The app is a shell around the same dashboard and carries its own `ocx` sidecar, so installing it +does not replace a CLI installation and does not move the state directory above. + +| Platform | Installed at | +|---|---| +| macOS | `/Applications/OpenCodex.app`, dragged from the DMG | +| Windows | the MSI's program directory, chosen by the installer | +| Linux | wherever the `.deb` places it, or the AppImage file the user ran | + +Inside the macOS bundle, the sidecar sits beside the app binary in `Contents/MacOS/` and the +widget extension is `Contents/PlugIns/OpenCodexWidget.appex`. Removing the app removes both; +it does not remove `~/.opencodex`, and it does not stop a service installed by the CLI. + +Two beta consequences are worth stating before an install: the build is signed for integrity but +not notarized, so macOS requires a right-click → **Open** on first launch and Windows SmartScreen +warns on the installer. Neither is a failure to route around by disabling a security setting on +the user's behalf — relay it and let the user decide. + ## Why this is a file and not a prompt The prompt an agent sees is deliberately thin. Printing the full rule on every diff --git a/README.md b/README.md index d5a2b010cf0..ec64c3de89d 100644 --- a/README.md +++ b/README.md @@ -91,24 +91,33 @@ Open **http://localhost:10100** and configure everything in the web dashboard (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. -### macOS desktop app and widget +
+Desktop app and macOS widget — beta + +A native shell around the same dashboard, plus a WidgetKit extension that shows proxy status, +today's usage and provider quotas without opening a browser. The proxy is unchanged: the app +finds a running one or starts the bundled `ocx` sidecar, and the dashboard stays at +**http://localhost:10100**. -Download the desktop app for macOS, Windows, or Linux from the -[latest releases](https://github.com/lidge-jun/opencodex/releases). +It is beta. Builds are signed for integrity but not notarized, so macOS asks for a +right-click → **Open** on first launch and Windows SmartScreen warns on the installer. The +widget needs macOS 14 or newer; the snapshot model it renders lives in [`app/`](./app) +(`MenuBarCore`). -A native desktop app and WidgetKit extension for proxy status, usage, and provider -quotas without opening the dashboard. The snapshot model lives in [`app/`](./app) -(`MenuBarCore`). Download it from the -[releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. +Download it from the [latest release](https://github.com/lidge-jun/opencodex/releases), or build +it locally with `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. -The first launch needs a right-click → Open, because the app is ad-hoc signed rather -than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) -for the full explanation. +Install locations, service files and everything else written to disk are listed in +[`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md#where-things-are-installed). The +[Desktop App guide](https://lidge-jun.github.io/opencodex/guides/desktop-app/) and the +[macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) cover +per-platform installation and the Gatekeeper prompt. + +
-The app also includes a macOS 14+ widget for proxy status, today's usage, and quotas. +### ChatGPT account pool -It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, +opencodex can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex threads normally retain affinity to the account that started them, so long SSH, tmux, or diff --git a/readme/README.fr.md b/readme/README.fr.md index 446bdaaf104..be965be6d9a 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -92,23 +92,37 @@ Ouvrez **http://localhost:10100** et configurez tout dans le tableau de bord web fournisseurs (plus de 40 intégrés, ou n'importe quel point de terminaison compatible OpenAI), choisissez les modèles, gérez les comptes. `ocx gui` rouvre le tableau de bord à tout moment. -### Application macOS dans la barre des menus -Téléchargez l’application de bureau pour macOS, Windows ou Linux depuis la -[page des releases](https://github.com/lidge-jun/opencodex/releases). - -Un compagnon natif pour l’état du proxy, l’utilisation et les quotas des fournisseurs sans ouvrir -le tableau de bord. Le code source se trouve dans [`app/`](../app) (Swift + AppKit, sans dépendance -tierce). Téléchargez-le depuis la -[page des releases](https://github.com/lidge-jun/opencodex/releases) ou compilez-le localement avec +
+Application de bureau et widget macOS — bêta + +Une application native qui reprend le même tableau de bord, accompagnée d’une extension WidgetKit qui +affiche l’état du proxy, l’utilisation du jour et les quotas des fournisseurs sans ouvrir de +navigateur. Le proxy ne change pas : l’application détecte une instance en cours d’exécution ou +démarre le sidecar `ocx` inclus, tandis que le tableau de bord reste accessible à l’adresse +**http://localhost:10100**. + +Cette version est en bêta. Les versions distribuées sont signées pour en garantir l’intégrité, mais ne sont pas +notariées : macOS demande donc un clic droit → **Ouvrir** au premier lancement, et Windows +SmartScreen affiche un avertissement pour le programme d’installation. Le widget nécessite macOS 14 +ou une version ultérieure ; le modèle de données des instantanés qu’il affiche se trouve dans [`app/`](../app) +(`MenuBarCore`). + +Téléchargez l’application depuis la [dernière version publiée](https://github.com/lidge-jun/opencodex/releases), +ou compilez-la localement avec `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. -Le premier lancement nécessite un clic droit → Ouvrir, car l’application est signée ad hoc et non -notarisée. Consultez le [guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) -pour l’explication complète. +Les emplacements d’installation, les fichiers de service et tous les autres éléments écrits sur le +disque sont répertoriés dans [`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed). +Le [guide de l’application de bureau](https://lidge-jun.github.io/opencodex/guides/desktop-app/) et le +[guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +détaillent l’installation sur chaque plateforme et le message de Gatekeeper. + +
+ +### Groupe de comptes ChatGPT -L’application inclut également un widget macOS 14+ affichant l’état du proxy, l’utilisation du jour et les quotas. -Il peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs +opencodex peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs comptes ChatGPT / Codex et actualisez leurs quotas 5 h / hebdomadaires / 30 j dans le tableau de bord. Avec le routage par quota, les nouvelles sessions peuvent utiliser le compte opérationnel le moins sollicité ; les modes round-robin et fill-first appliquent leurs propres politiques. Les fils Codex existants restent diff --git a/readme/README.ja.md b/readme/README.ja.md index 7d8369c0285..9d32a7bfc7f 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -90,28 +90,41 @@ ocx start # プロキシとダッシュボードが loca **http://localhost:10100** を開き、Web ダッシュボードですべて設定します。プロバイダーの追加(40 以上の 組み込み、または任意の OpenAI 互換エンドポイント)、モデルの選択、アカウントの管理はここで行います。 `ocx gui` でいつでもダッシュボードを開き直せます。 -Codex 認証用の **ChatGPT アカウントプール**も管理できます。ChatGPT / Codex のアカウントを複数追加し、 -5 時間 / 週間 / 30 日のクォータをダッシュボードで更新します。クォータルーティングでは、新しいセッション -が使用量の最も少ない健全なアカウントを使えます。ラウンドロビンと fill-first はそれぞれの方針に従います。 -既存の Codex スレッドは通常、開始したアカウントとの affinity を保つので、長い SSH・tmux・モバイル接続 -のセッションが会話の途中でアカウントを乗り換えることはありません。ただしクォータの再評価、failover、 -アカウントの除外、affinity の失効、401/403 や 429 からの復帰では再バインドされることがあります。ふだん -は使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、アカウント -に選択順を指定してください。 - -### macOS メニューバーアプリ - -macOS、Windows、Linux 向けのデスクトップアプリを[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードできます。 - -ダッシュボードを開かずにプロキシの状態、使用量、プロバイダーのクォータを確認できるネイティブ -コンパニオンです。ソースは [`app/`](../app)(Swift + AppKit、サードパーティ依存なし)にあります。 -[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 + +
+デスクトップアプリと macOS ウィジェット — ベータ版 + +同じダッシュボードを包むネイティブアプリに、ブラウザーを開かなくてもプロキシの状態、今日の使用量、 +プロバイダーのクォータを確認できる WidgetKit 拡張を加えたものです。プロキシ自体は変わりません。アプリは +起動中のプロキシを見つけるか、同梱の `ocx` サイドカーを起動し、ダッシュボードは引き続き +**http://localhost:10100** で開きます。 + +現在はベータ版です。ビルドは改ざん検知のため署名されていますが公証はされていないため、macOS では +初回起動時に右クリックして「開く」を選ぶ必要があり、Windows ではインストーラーに SmartScreen の警告が +表示されます。ウィジェットには macOS 14 以降が必要です。表示に使うスナップショットモデルは +[`app/`](../app)(`MenuBarCore`)にあります。 + +[最新リリース](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` でローカルビルドできます。 -アプリは未公証のアドホック署名のため、初回起動時は右クリックして「開く」を選択してください。 -詳しくは [macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)をご覧ください。 +インストール先、サービスファイルなどディスクに書き込まれるものは +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) にまとめています。 +[デスクトップアプリガイド](https://lidge-jun.github.io/opencodex/guides/desktop-app/)と +[macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)では、 +プラットフォーム別のインストール手順と Gatekeeper の確認画面を説明しています。 + +
+ +### ChatGPT アカウントプール -macOS 14 以降では、プロキシの状態、今日の使用量、クォータを表示するウィジェットも利用できます。 +opencodex では、Codex 認証用の **ChatGPT アカウントプール**も管理できます。ChatGPT / Codex のアカウントを +複数追加し、5 時間 / 週間 / 30 日のクォータをダッシュボードで更新します。クォータルーティングでは、新しい +セッションが使用量の最も少ない健全なアカウントを使えます。ラウンドロビンと fill-first はそれぞれの方針に +従います。既存の Codex スレッドは通常、開始したアカウントとの affinity を保つので、長い SSH・tmux・ +モバイル接続のセッションが会話の途中でアカウントを乗り換えることはありません。ただしクォータの再評価、 +failover、アカウントの除外、affinity の失効、401/403 や 429 からの復帰では再バインドされることがあります。 +ふだんは使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、 +アカウントに選択順を指定してください。 ### スポンサー diff --git a/readme/README.ko.md b/readme/README.ko.md index c1bafb47fe7..1dc97b5b1fc 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -89,26 +89,38 @@ ocx start # 프록시 + 대시보드: localhost:10100 **http://localhost:10100**을 열고 웹 대시보드에서 전부 설정하세요. 프로바이더 추가(내장 40개 이상, 또는 OpenAI 호환 엔드포인트), 모델 선택, 계정 관리까지 모두 여기서 합니다. `ocx gui`로 대시보드를 언제든 다시 엽니다. -Codex 인증용 **ChatGPT 계정 풀**도 관리합니다. ChatGPT / Codex 계정을 여러 개 넣고, 대시보드에서 -5시간 / 주간 / 30일 쿼터를 갱신합니다. 쿼터 라우팅을 켜면 새 세션은 사용량이 가장 적은 정상 계정을 쓰고, -round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레드는 기본적으로 시작한 계정에 붙어 -있어서, 긴 SSH·tmux·모바일 세션이 대화 도중에 계정을 바꾸지 않습니다. 다만 쿼터 재평가, failover, -계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. Codex Desktop 로그인처럼 -다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면, 계정에 선택 순서를 지정하세요. -### macOS 메뉴 막대 앱 +
+데스크톱 앱과 macOS 위젯 — 베타 + +같은 대시보드를 감싼 네이티브 앱과, 브라우저를 열지 않고 프록시 상태·오늘의 사용량·프로바이더 +쿼터를 보여 주는 WidgetKit 확장입니다. 프록시 자체는 그대로입니다. 앱은 실행 중인 프록시를 찾거나 +번들된 `ocx` 사이드카를 시작하며, 대시보드는 계속 **http://localhost:10100**에서 열립니다. + +현재 베타 버전입니다. 빌드는 무결성을 확인할 수 있도록 서명되어 있지만 공증되지는 않았습니다. +따라서 macOS에서는 처음 실행할 때 마우스 오른쪽 버튼을 클릭한 뒤 **열기**를 선택해야 하고, +Windows 설치 파일에는 SmartScreen 경고가 표시됩니다. 위젯은 macOS 14 이상에서 쓸 수 있으며, +위젯이 그리는 스냅샷 모델은 [`app/`](../app)의 `MenuBarCore`에 있습니다. -macOS, Windows, Linux용 데스크톱 앱은 [릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하세요. +[최신 릴리스](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`로 직접 빌드하세요. -대시보드를 열지 않고 프록시 상태, 사용량, 제공자 쿼터를 확인하는 네이티브 동반 앱입니다. -소스는 [`app/`](../app)에 있으며 Swift + AppKit으로 작성되었고 서드파티 의존성이 없습니다. -[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`로 직접 빌드할 수 있습니다. +설치 위치, 서비스 파일을 비롯해 디스크에 쓰는 항목은 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)에 정리되어 있습니다. +[데스크톱 앱 가이드](https://lidge-jun.github.io/opencodex/guides/desktop-app/)와 +[macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)에서 +플랫폼별 설치 방법과 Gatekeeper 안내를 확인할 수 있습니다. + +
-앱은 공증되지 않은 애드혹 서명이므로 처음 실행할 때 마우스 오른쪽 버튼을 클릭하고 열기를 선택하세요. -자세한 내용은 [macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)를 참조하세요. +### ChatGPT 계정 풀 -macOS 14 이상에서는 프록시 상태, 오늘의 사용량과 쿼터를 보여 주는 위젯도 포함됩니다. +opencodex는 Codex 인증용 **ChatGPT 계정 풀**도 관리합니다. ChatGPT / Codex 계정을 여러 개 넣고, +대시보드에서 5시간 / 주간 / 30일 쿼터를 갱신합니다. 쿼터 라우팅을 켜면 새 세션은 사용량이 가장 적은 +정상 계정을 쓰고, round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레드는 기본적으로 +시작한 계정에 붙어 있어서 긴 SSH·tmux·모바일 세션이 대화 도중에 계정을 바꾸지 않습니다. 다만 쿼터 +재평가, failover, 계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. +Codex Desktop 로그인처럼 다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면 계정에 선택 순서를 지정하세요. ### 스폰서 diff --git a/readme/README.ru.md b/readme/README.ru.md index e470afd26c2..e12ab125df8 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -92,7 +92,34 @@ ocx start # прокси + панель управлен Откройте **http://localhost:10100** и настройте всё в веб-панели: добавьте провайдеров (40+ встроенных или любой OpenAI-совместимый endpoint), выберите модели, управляйте аккаунтами. `ocx gui` в любой момент снова откроет панель. -Кроме того, он умеет управлять **пулом аккаунтов ChatGPT** для аутентификации Codex. Добавьте + +
+Настольное приложение и виджет macOS — бета-версия + +Это нативная оболочка вокруг той же панели управления с расширением WidgetKit, которое +показывает состояние прокси, расход за сегодня и квоты провайдеров без открытия браузера. +Сам прокси не меняется: приложение находит уже запущенный экземпляр либо запускает встроенный +сайдкар `ocx`, а панель по-прежнему доступна по адресу **http://localhost:10100**. + +Это бета-версия. Сборки подписаны для проверки целостности, но не нотариализованы, поэтому +при первом запуске macOS просит нажать правой кнопкой мыши и выбрать **«Открыть»**, а Windows +SmartScreen предупреждает об установщике. Для виджета нужна macOS 14 или новее; его модель +снимков находится в [`app/`](../app) (`MenuBarCore`). + +Скачайте приложение из [последнего релиза](https://github.com/lidge-jun/opencodex/releases) или +соберите локально командой `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. + +Пути установки, файлы служб и всё остальное, что записывается на диск, перечислены в +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed). В +[руководстве по настольному приложению](https://lidge-jun.github.io/opencodex/guides/desktop-app/) и +[руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +описаны установка на каждой платформе и запрос Gatekeeper. + +
+ +### Пул аккаунтов ChatGPT + +opencodex также умеет управлять **пулом аккаунтов ChatGPT** для аутентификации Codex. Добавьте несколько аккаунтов ChatGPT / Codex и обновляйте их квоты за 5 ч / неделю / 30 дней в панели. При маршрутизации по квоте новые сессии могут использовать работоспособный аккаунт с наименьшим использованием; round-robin и fill-first применяют свои политики. Существующие треды Codex @@ -103,21 +130,6 @@ ocx start # прокси + панель управлен них — обычно вход Codex Desktop — должен использоваться только после того, как остальные исчерпаны. -### Приложение macOS в строке меню - -Скачайте настольное приложение для macOS, Windows или Linux со -[страницы релизов](https://github.com/lidge-jun/opencodex/releases). - -Нативный компаньон для состояния прокси, использования и квот провайдеров без открытия панели. -Исходный код находится в [`app/`](../app) (Swift + AppKit, без сторонних зависимостей). -Скачайте его со [страницы релизов](https://github.com/lidge-jun/opencodex/releases) или -соберите локально командой `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. - -При первом запуске нажмите правой кнопкой мыши и выберите «Открыть»: приложение подписано ad hoc, -но не нотариализовано. Подробности — в [руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/). - -Приложение также включает виджет для macOS 14+, показывающий состояние прокси, расход за сегодня и квоты. - ### Спонсоры Спонсоры позволяют поддерживать opencodex при каждом изменении вышестоящих протоколов. Интересно? diff --git a/readme/README.tr.md b/readme/README.tr.md index ea79384a412..083e8e46370 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -90,28 +90,42 @@ Arka planda çalıştırmak için `ocx service` kullanın. **http://localhost:10100** adresini açın ve her şeyi web kontrol panelinden yapılandırın: sağlayıcı ekleyin (40'tan fazla hazır sağlayıcı ya da herhangi bir OpenAI uyumlu uç nokta), model seçin, hesap yönetin. `ocx gui` paneli istediğiniz zaman yeniden açar. -Codex kimlik doğrulaması için bir **ChatGPT hesap havuzu** da yönetebilir. Birden fazla ChatGPT / Codex -hesabı ekleyin, 5 saatlik / haftalık / 30 günlük kotalarını panelden tazeleyin. Kota yönlendirmesinde -yeni oturumlar en az kullanılan sağlıklı hesabı kullanabilir; round-robin ve fill-first kendi -politikalarını izler. Mevcut Codex dizileri normalde onları başlatan hesaba bağlı kalır, böylece uzun -SSH, tmux ya da mobil oturumlar konuşmanın ortasında hesap değiştirmez — ancak kota yeniden -değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığın süresinin dolması ya da 401/403 ve -429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde kullanılmasını istediğiniz -bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim sırası verin. -### macOS menü çubuğu uygulaması +
+Masaüstü uygulaması ve macOS widget'ı — beta + +Aynı kontrol panelini sarmalayan yerel uygulamaya ek olarak, tarayıcı açmadan proxy durumunu, +bugünkü kullanımı ve sağlayıcı kotalarını gösteren bir WidgetKit uzantısı sunulur. Proxy'nin çalışma +şekli değişmez: uygulama çalışan bir proxy bulur ya da paketlenmiş `ocx` sidecar'ını başlatır; +kontrol paneli yine **http://localhost:10100** adresinde kalır. -macOS, Windows veya Linux masaüstü uygulamasını [sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin. +Bu bir beta sürümüdür. Derlemeler bütünlük için imzalanır ancak noter tasdikli değildir; bu nedenle +macOS ilk açılışta sağ tıklayıp **Aç**'ı seçmenizi ister, Windows SmartScreen ise yükleyici için uyarı +gösterir. Widget için macOS 14 veya üzeri gerekir; görüntülediği anlık görüntü modeli +[`app/`](../app) dizinindedir (`MenuBarCore`). -Panoyu açmadan proxy durumunu, kullanımı ve sağlayıcı kotalarını gösteren yerel yardımcı uygulama. -Kaynak kodu [`app/`](../app) konumundadır (Swift + AppKit, üçüncü taraf bağımlılığı yoktur). -[Sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin veya -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` ile yerel olarak derleyin. +Uygulamayı [en güncel sürümden](https://github.com/lidge-jun/opencodex/releases) indirin veya +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` komutuyla yerel olarak derleyin. -Uygulama noter tasdikli olmadığından ve ad hoc imzalandığından ilk açılışta sağ tıklayıp Aç'ı seçin. -Ayrıntılar için [macOS menü çubuğu uygulaması kılavuzuna](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) bakın. +Kurulum konumları, servis dosyaları ve diske yazılan diğer her şey +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) dosyasında listelenir. +[Masaüstü uygulaması kılavuzu](https://lidge-jun.github.io/opencodex/guides/desktop-app/) ve +[macOS menü çubuğu uygulaması kılavuzu](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/), +platforma göre kurulumu ve Gatekeeper istemini açıklar. + +
-Uygulama ayrıca proxy durumunu, bugünkü kullanımı ve kotaları gösteren macOS 14+ widget'ını içerir. +### ChatGPT hesap havuzu + +opencodex, Codex kimlik doğrulaması için bir **ChatGPT hesap havuzu** da yönetebilir. Birden fazla +ChatGPT / Codex hesabı ekleyin, 5 saatlik / haftalık / 30 günlük kotalarını panelden tazeleyin. Kota +yönlendirmesinde yeni oturumlar en az kullanılan sağlıklı hesabı kullanabilir; round-robin ve +fill-first kendi politikalarını izler. Mevcut Codex dizileri normalde onları başlatan hesaba bağlı +kalır, böylece uzun SSH, tmux ya da mobil oturumlar konuşmanın ortasında hesap değiştirmez — ancak +kota yeniden değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığın süresinin dolması +ya da 401/403 ve 429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde +kullanılmasını istediğiniz bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim +sırası verin. ### Sponsorlar diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 18e0c506c07..e4cd96d518e 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -89,7 +89,33 @@ ocx start # 代理 + 仪表板:localhost:10100 打开 **http://localhost:10100**,在 Web 仪表板中完成所有配置 —— 添加提供商 (40 多个内置,或任意 OpenAI 兼容端点)、选择模型、管理账户。随时运行 `ocx gui` 可重新打开仪表板。 -它还能为 Codex 认证管理一个 **ChatGPT 账户池**。添加多个 ChatGPT / Codex 账户, + +
+桌面应用与 macOS 小组件 —— 测试版 + +它是同一套仪表板的原生外壳,另带 WidgetKit 扩展,无需打开浏览器即可查看代理状态、 +今日用量和提供商配额。代理本身没有变化:应用会连接已在运行的代理;若未发现, +则启动内置的 `ocx` sidecar。仪表板仍位于 **http://localhost:10100**。 + +桌面应用目前仍处于测试阶段。构建已签名以保障完整性,但尚未公证,因此 macOS +首次启动时需要右键点击并选择“打开”,Windows SmartScreen 也会对安装程序发出警告。 +小组件需要 macOS 14 或更高版本;它所呈现的快照模型位于 [`app/`](../app) +(`MenuBarCore`)。 + +请从[最新发布版本](https://github.com/lidge-jun/opencodex/releases)下载,或使用 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本地构建。 + +安装位置、服务文件以及写入磁盘的其他内容均列在 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) 中。 +[桌面应用指南](https://lidge-jun.github.io/opencodex/guides/desktop-app/)和 +[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +介绍了各平台的安装方式和 Gatekeeper 提示。 + +
+ +### ChatGPT 账户池 + +opencodex 还能为 Codex 认证管理一个 **ChatGPT 账户池**。添加多个 ChatGPT / Codex 账户, 在仪表板中刷新它们的 5 小时 / 每周 / 30 天配额。在配额路由下,新会话可以使用 使用量最低的健康账户;round-robin 和 fill-first 则各自使用自己的策略。现有 Codex 线程通常会保持对启动它的账户的亲和性,因此长时间的 SSH、tmux 或移动端连接的会话 @@ -97,19 +123,6 @@ ocx start # 代理 + 仪表板:localhost:10100 401/403 与 429 恢复,仍可能重新绑定。给账户设定选择顺序,以便其中某个账户 —— 通常是你的 Codex Desktop 登录 —— 只在其他账户耗尽后才被选中。 -### macOS 菜单栏应用 - -请从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 macOS、Windows 或 Linux 桌面应用。 - -无需打开仪表板即可查看代理状态、用量和提供商配额的原生伴侣应用。源代码位于 -[`app/`](../app)(Swift + AppKit,无第三方依赖)。请从[发布页面](https://github.com/lidge-jun/opencodex/releases) -下载,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本地构建。 - -应用采用未公证的临时签名,首次启动时请右键点击并选择“打开”。详情请参阅 -[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 - -应用还包含适用于 macOS 14 及更高版本的小组件,可显示代理状态、今日用量和配额。 - ### 赞助商 赞助商支撑 opencodex 跟上每一次上游协议变更。有兴趣? diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 828cb1fed64..70b0cae1276 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -88,25 +88,37 @@ ocx start # 代理 + 儀表板位於 localhost:10100 開啟 **http://localhost:10100**,在網頁儀表板完成所有設定——新增供應商 (40+ 內建,或任何 OpenAI 相容端點)、挑選模型、管理帳號。隨時可用 `ocx gui` 重新開啟儀表板。 -它也能為 Codex 認證管理 **ChatGPT 帳號池**。新增多個 ChatGPT / Codex 帳號, -在儀表板重新整理 5 小時/每週/30 天配額。在配額路由下,新會話可使用 -使用量最低的健康帳號;round-robin 與 fill-first 則各自套用自己的策略。既有 Codex -執行緒通常會維持對啟動帳號的親和性,因此長時間的 SSH、tmux 或 -行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 -帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 -### macOS 選單列應用程式 +
+桌面應用程式與 macOS 小工具——Beta 版 + +它是同一套儀表板的原生外殼,另附 WidgetKit 擴充套件,無需開啟瀏覽器就能查看代理狀態、 +今日用量與供應商配額。代理本身沒有改變:應用程式會尋找正在執行的代理,找不到便啟動隨附的 +`ocx` sidecar;儀表板仍位於 **http://localhost:10100**。 -請從[發行頁面](https://github.com/lidge-jun/opencodex/releases)下載 macOS、Windows 或 Linux 桌面應用程式。 +目前仍是 Beta 版。建置會簽章以確保完整性,但尚未經公證,因此 macOS 在首次啟動時需要按右鍵 → **開啟**, +Windows SmartScreen 則會對安裝程式顯示警告。小工具需要 macOS 14 或更新版本;它所呈現的快照模型位於 +[`app/`](../app)(`MenuBarCore`)。 -無需開啟儀表板即可查看代理狀態、用量與供應商配額的原生伴侶應用程式。原始碼位於 -[`app/`](../app)(Swift + AppKit,沒有第三方相依套件)。請從[發行頁面](https://github.com/lidge-jun/opencodex/releases) -下載,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本機建置。 +請從[最新發行版](https://github.com/lidge-jun/opencodex/releases)下載,或使用 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本機建置。 -應用程式未經公證且使用 ad hoc 簽章,首次啟動時請按右鍵並選擇「開啟」。詳情請參閱 -[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 +安裝位置、服務檔案,以及其他寫入磁碟的內容,都列在 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)。 +[桌面應用程式指南](https://lidge-jun.github.io/opencodex/guides/desktop-app/) 與 +[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +說明各平台的安裝方式與 Gatekeeper 提示。 -應用程式也包含 macOS 14 以上的小工具,可顯示代理狀態、今日用量與配額。 +
+ +### ChatGPT 帳號池 + +opencodex 也能為 Codex 認證管理 **ChatGPT 帳號池**。新增多個 ChatGPT / Codex 帳號, +在儀表板重新整理 5 小時/每週/30 天配額。在配額路由下,新會話可使用 +使用量最低的健康帳號;round-robin 與 fill-first 則各自套用自己的策略。既有 Codex +執行緒通常會維持對啟動帳號的親和性,因此長時間的 SSH、tmux 或 +行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 +帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 ### 贊助 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index a5e76f90b73..1bdee0719bc 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" } } } From cfbe9a91b9bca21f7613a8270c866b1220b97a2f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:32:07 +0900 Subject: [PATCH 78/87] fix(desktop): let the bootstrap surface stay silent until it has something to report (#5412) --- desktop/ui/index.html | 1 + desktop/ui/main.js | 26 +++++++++++++++-- tests/clients/desktop-startup-surface.test.ts | 28 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 0a10a6e4534..f42074944ce 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -18,6 +18,7 @@ #phases li[data-state="done"] { color: #4b8b3b; } #phases li[data-state="failed"] { color: #b3261e; font-weight: 600; } #failure { margin-top: 1.25rem; display: grid; gap: .75rem; } + #failure[hidden] { display: none; } .actions { display: flex; gap: .6rem; align-items: center; } button { border: 0; border-radius: .5rem; padding: .6rem 1rem; background: #2563eb; color: white; cursor: pointer; font: inherit; } button.secondary { background: #e3e3e8; color: #202124; } diff --git a/desktop/ui/main.js b/desktop/ui/main.js index 3f4516bda1a..6431ac08230 100644 --- a/desktop/ui/main.js +++ b/desktop/ui/main.js @@ -16,6 +16,26 @@ const bridge = window.__TAURI__; const invoke = bridge && bridge.core && bridge.core.invoke; const listen = bridge && bridge.event && bridge.event.listen; +// The shell owns the sequence and its deadline. The page has no deadline of its own: an invoke +// whose command never answers returns a promise that neither settles nor rejects, and the page +// then keeps its initial markup forever - the headline still says the run is starting, the +// checklist is empty, and the only thing on screen is a Retry button with an empty diagnostic. +// That is indistinguishable from a hung product. Bounding the handshake turns the silence into a +// failure the page can report and the user can copy. +const HANDSHAKE_DEADLINE_MS = 5000; + +function withDeadline(work, what) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('the shell did not answer ' + what + ' within ' + HANDSHAKE_DEADLINE_MS + ' ms')); + }, HANDSHAKE_DEADLINE_MS); + Promise.resolve(work).then( + (value) => { clearTimeout(timer); resolve(value); }, + (error) => { clearTimeout(timer); reject(error); }, + ); + }); +} + const headline = document.querySelector("#headline"); const detail = document.querySelector("#detail"); const phaseList = document.querySelector("#phases"); @@ -120,12 +140,12 @@ async function start() { return; } try { - phases = (await invoke("startup_phases")).filter((phase) => !phase.terminal); + phases = (await withDeadline(invoke("startup_phases"), "startup_phases")).filter((phase) => !phase.terminal); render(null); // The listener goes on before the snapshot is read, so a transition landing between the two is // delivered rather than lost. - await listen("startup-phase", (event) => apply(event.payload)); - apply(await invoke("startup_snapshot")); + await withDeadline(listen("startup-phase", (event) => apply(event.payload)), "the startup-phase subscription"); + apply(await withDeadline(invoke("startup_snapshot"), "startup_snapshot")); } catch (error) { reportPageFailure("The startup surface could not reach the shell.", error); } diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 781416398c5..2f1e6876fd6 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -197,3 +197,31 @@ describe("desktop startup surface", () => { expect(page).toContain("clipboard.writeText"); }); }); + +/** + * The surface has to be able to stay silent. + * + * Two defects made it speak when it had nothing to say and stay quiet when it did. An id rule + * with display: grid outranks the user-agent [hidden] { display: none } rule, so the failure + * block - the Retry button and the empty diagnostic box - was painted during every normal start. + * And an invoke whose command never answers returns a promise that neither settles nor rejects, + * so the page kept its initial markup for as long as the shell stayed silent. Together they are + * the screen a user reads as a dead application: a starting headline, no checklist, one Retry. + */ +describe("the bootstrap page reports only what it was told", () => { + const markup = readFileSync(repoPath("desktop/ui/index.html"), "utf8"); + const page = readFileSync(PAGE, "utf8"); + + test("the failure block honours its hidden attribute", () => { + expect(/#failure\[hidden\][^{]*\{[^}]*display:\s*none/.test(markup)).toBe(true); + }); + + test("the handshake with the shell is bounded", () => { + expect(page).toContain("HANDSHAKE_DEADLINE_MS"); + for (const command of ["startup_phases", "startup_snapshot"]) { + const bounded = 'withDeadline(invoke(\"' + command + '\")'; + expect(page.includes(bounded)).toBe(true); + } + expect(page).toContain("withDeadline(listen("); + }); +}); From e4aebd7ba10fde68122b7eb5a0c4b17357a47121 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:37:54 +0900 Subject: [PATCH 79/87] feat(release): verify everything before publication and add a resumable publish path (#5405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(release): add the pre-publication asset verifier The release pipeline verified checksums and generated the updater manifest inside attach-release, a job that runs after publication and is skipped on dry-run. The guarantee that gives is "packaging finished before publish"; the guarantee a release needs is "everything about to be published was verified valid before publish". desktop/scripts/verify-release-assets.ts is the verification authority. It derives the expected platform file set from the release workflow's own packaging matrices and the producer tables (build-standalone targets, collect-release-assets bundle names), verifies every recorded checksum against the bytes on disk with the bare-name rule the flat verification directory requires, verifies every updater signature cryptographically against the minisign public key pinned in tauri.conf.json (pure Ed25519 "Ed" mode, the form the Tauri bundler emits; the prehashed "ED" mode fails loudly rather than mis-verifying), generates the updater manifest and parses it back against the files it names, and writes a machine-readable receipt that a later stage can require. bundlesByTarget and platformFiles are exported from their owning scripts, and the standalone target set, archive naming, and executable naming move into scripts/standalone-targets.ts, which the builder and the verifier share — a target added to one side without the other fails verification, not the release. Unit tests in release-desktop-scripts.test.ts cover the derivation against the real workflow, checksum acceptance and the three refusal modes, signature verification with real Ed25519 fixtures (tampered payload, foreign key id, unsupported algorithm), and the full flow including the receipt. They were reviewed statically and are first executed by hosted CI. * feat(release): verify everything before publication and add a resumable publish path The pipeline now has a verify-release job between packaging and publication. It downloads the packaged artifacts, runs the verifier over them — expected platform set, checksums, updater signatures, manifest generation and parse-back — and publishes the verified bundle plus the verification receipt. publish waits for verify-release instead of verifying nothing, and attach-release downloads the verified bundle and refuses to upload unless the receipt names this run's version and commit. Checksum verification and latest.json generation moved out of attach-release into verify-release, so they now run on dry-run too: a dry run proves the same chain a real release relies on. npm and GitHub are not published atomically, so a run that acknowledged npm publication and failed afterwards needs a path that completes the GitHub side without republishing. The new resume-after-npm-publish dispatch input is that path: the preflight requires the version to already exist on npm and refuses to combine with dry-run, the publish step skips npm publish while still emitting the publication receipt the downstream steps gate on, and release creation is idempotent so a release left behind by the failed run is reused for attachment. A successful publish records these recovery instructions in the job summary at the moment they matter. The workflow-contract tests assert the new ordering graph, the absence of verification steps in attach-release, the receipt gate's ordering before the upload, and the recovery branches; the publish-needs assertion in ci-workflows.test.ts follows the new graph. Release automation changed, so this carries the explicit security review the repository requires: no permissions blocks change, no secrets are added or re-scoped, and verification (commit 1) is reviewable separately from publication ordering and the recovery input (this commit). --- .github/workflows/release.yml | 175 +++++++-- desktop/scripts/collect-release-assets.ts | 4 +- desktop/scripts/updater-manifest.ts | 2 +- desktop/scripts/verify-release-assets.ts | 343 ++++++++++++++++++ scripts/build-standalone.ts | 13 +- scripts/standalone-targets.ts | 29 ++ structure/desktop-shell.md | 11 +- tests/ci-workflows/ci-workflows.test.ts | 11 +- .../release-desktop-scripts.test.ts | 240 ++++++++++++ .../release-pipeline-contract.test.ts | 83 ++++- tests/gui/standalone-build-script.test.ts | 6 +- 11 files changed, 850 insertions(+), 67 deletions(-) create mode 100644 desktop/scripts/verify-release-assets.ts create mode 100644 scripts/standalone-targets.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd99a10a9c7..ace74775e84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,11 @@ on: required: false type: boolean default: true + resume-after-npm-publish: + description: "Operator attestation: a previous run of this workflow acknowledged npm publication for this exact commit; skip npm publish and complete the GitHub side" + required: false + type: boolean + default: false expected-sha: description: "Immutable release commit this dispatch must publish (fail if the branch moved)" required: true @@ -156,9 +161,10 @@ jobs: tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui fi cd ../.. - # shasum -c in attach-release runs from dist/release, where the artifact download - # lands these files flat; the checksum therefore records the bare file name, which - # sha256sum takes verbatim from its argument. + # The pre-publication verifier resolves every recorded checksum from + # dist/release, where the artifact download lands these files flat; the + # checksum therefore records the bare file name, which sha256sum takes + # verbatim from its argument. if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" fi @@ -424,15 +430,19 @@ jobs: security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" fi - attach-release: + # Pre-publication verification. Everything that will be published is checked + # here — expected platform set, every checksum, the updater signatures, and the + # manifest parse-back — and publication consumes this result rather than + # verifying after the fact. Runs on dry-run too: a dry run must prove the same + # chain a real release will rely on. + verify-release: runs-on: ubuntu-latest - needs: [publish, package-standalone, package-desktop] - if: ${{ inputs.dry-run != true }} - env: - UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + needs: [validate-dispatch, package-standalone, package-desktop] timeout-minutes: 10 permissions: - contents: write + contents: read + env: + UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -456,24 +466,86 @@ jobs: merge-multiple: true path: dist/release - # Generate latest.json only when the updater key is configured; then require - # signatures for all four updater platforms before publishing it. - - name: Generate updater manifest - if: env.UPDATER_SIGNING_CONFIGURED == 'true' + - name: Verify release assets env: RELEASE_VERSION: ${{ inputs.version }} run: | - bun desktop/scripts/updater-manifest.ts \ - --version "$RELEASE_VERSION" \ - --dir dist/release \ - --repo lidge-jun/opencodex \ - --out dist/release/latest.json \ - --require-all + set -euo pipefail + args=( + --version "$RELEASE_VERSION" + --dir dist/release + --repo "$GITHUB_REPOSITORY" + --sha "$GITHUB_SHA" + --receipt-out verification/receipt.json + ) + # Signatures are verified whenever they exist; the manifest is only + # generated when this run holds the updater key, exactly as before. + if [ "$UPDATER_SIGNING_CONFIGURED" = "true" ]; then + args+=(--manifest-out dist/release/latest.json --require-signatures) + fi + bun desktop/scripts/verify-release-assets.ts "${args[@]}" + + - name: Upload verified release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: verified-release + path: dist/release/ + if-no-files-found: error + retention-days: 7 - - name: Verify the checksum before uploading + - name: Upload verification receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-verification-receipt + path: verification/receipt.json + if-no-files-found: error + retention-days: 7 + + attach-release: + runs-on: ubuntu-latest + needs: [publish, verify-release] + if: ${{ inputs.dry-run != true }} + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the verified release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: verified-release + path: dist/release + + - name: Download the verification receipt + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-verification-receipt + path: verification + + # The bundle is attached exactly as verified: the receipt must name this + # run's version and commit, or nothing uploads. + - name: Require the verification receipt for this commit + env: + RELEASE_VERSION: ${{ inputs.version }} run: | - cd dist/release - shasum -a 256 -c ./*.sha256 + set -euo pipefail + receipt_version="$(bun -e 'console.log(JSON.parse(await Bun.file("verification/receipt.json").text()).version)')" + receipt_sha="$(bun -e 'console.log(JSON.parse(await Bun.file("verification/receipt.json").text()).sha)')" + test "$receipt_version" = "$RELEASE_VERSION" || { + echo "::error::verification receipt names version $receipt_version, not $RELEASE_VERSION" + exit 1 + } + test "$receipt_sha" = "$GITHUB_SHA" || { + echo "::error::verification receipt names commit $receipt_sha, not $GITHUB_SHA" + exit 1 + } - name: Attach to the release env: @@ -485,7 +557,7 @@ jobs: gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber publish: - needs: [validate-dispatch, package-standalone, package-desktop] + needs: [validate-dispatch, verify-release] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -676,6 +748,7 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry-run }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail @@ -692,7 +765,9 @@ jobs: fi if [ -n "$existing_tag_sha" ]; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::${release_tag} already exists at this commit; resuming" + elif [ "$dry_run" = "true" ]; then echo "::notice::${release_tag} already exists at this commit; dry-run only" else echo "::error::${release_tag} already exists. Refusing to publish a version with pre-existing Git metadata." @@ -701,7 +776,9 @@ jobs: fi if gh release view "$release_tag" >/dev/null 2>&1; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::GitHub Release ${release_tag} already exists; resuming to complete the attachment" + elif [ "$dry_run" = "true" ]; then echo "::notice::GitHub Release ${release_tag} already exists; dry-run only" else echo "::error::GitHub Release ${release_tag} already exists. Choose the next unused patch version." @@ -709,24 +786,36 @@ jobs: fi fi + if [ "$RESUME" = "true" ] && [ "$dry_run" = "true" ]; then + echo "::error::resume-after-npm-publish is a real-publication recovery path and cannot combine with dry-run" + exit 1 + fi if npm view "${pkg_name}@${RELEASE_VERSION}" version >/dev/null 2>&1; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::${pkg_name}@${RELEASE_VERSION} is acknowledged on npm; resuming after the recorded partial publication" + elif [ "$dry_run" = "true" ]; then echo "::notice::${pkg_name}@${RELEASE_VERSION} already exists on npm; dry-run only" else - echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. Choose the next unused patch version." + echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. If a previous run acknowledged this publication and failed afterwards, re-dispatch with resume-after-npm-publish: true; otherwise choose the next unused patch version." exit 1 fi + elif [ "$RESUME" = "true" ]; then + echo "::error::resume-after-npm-publish is set, but ${pkg_name}@${RELEASE_VERSION} is not on npm — there is no acknowledged publication to resume from" + exit 1 fi - name: Refuse a release the current tag set already outranks env: RELEASE_VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry-run }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail allow="" existing_tag_sha="$(git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}^{commit}" || true)" - if [ "$DRY_RUN" = "true" ] && [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then + # Dry-run re-dispatches and the resume path both legitimately find the tag + # already at this commit; a moved tag is still refused above. + if { [ "$DRY_RUN" = "true" ] || [ "$RESUME" = "true" ]; } && [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then allow="--allow-existing-tag-at-head" fi git tag --list 'v*' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" $allow @@ -755,15 +844,25 @@ jobs: env: DRY_RUN: ${{ inputs.dry-run }} NPM_DIST_TAG: ${{ inputs.tag }} + RESUME: ${{ inputs.resume-after-npm-publish }} + RELEASE_VERSION: ${{ inputs.version }} run: | set -euo pipefail - if [ "$DRY_RUN" = "true" ]; then + pkg_name="$(node -p "require('./package.json').name")" + if [ "$RESUME" = "true" ]; then + # npm publication was acknowledged by the earlier run and confirmed by the + # preflight above; completing the GitHub side must never republish. + echo "::notice::RESUME — npm publish skipped; publication already acknowledged" + echo "published=true" >> "$GITHUB_OUTPUT" + echo "Publication resumed for ${pkg_name}@${RELEASE_VERSION} at ${GITHUB_SHA} (npm publish skipped; acknowledged by the earlier run)." >> "$GITHUB_STEP_SUMMARY" + elif [ "$DRY_RUN" = "true" ]; then echo "::notice::DRY RUN — building + packing, not publishing" npm run prepublishOnly npm pack --dry-run else npm publish --tag "$NPM_DIST_TAG" --access public echo "published=true" >> "$GITHUB_OUTPUT" + echo "Publication acknowledged for ${pkg_name}@${RELEASE_VERSION} at ${GITHUB_SHA}. If any later step in this run fails, re-dispatch with the same version and expected-sha plus resume-after-npm-publish: true — never republish this version." >> "$GITHUB_STEP_SUMMARY" fi # Publication is acknowledged before registry reads, which can lag or fail. @@ -805,6 +904,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail @@ -833,5 +933,18 @@ jobs: git push origin "refs/tags/${release_tag}" fi - gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ - --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + # Idempotent only for the resume path: a previous run may already have + # created the release and then failed before the assets were attached. + # Outside resume, finding a release here means the preflight was bypassed + # or the release appeared mid-run, and that stays a hard failure. + if gh release view "$release_tag" >/dev/null 2>&1; then + if [ "$RESUME" = "true" ]; then + echo "::notice::GitHub Release ${release_tag} already exists; reusing it for attachment" + else + echo "::error::GitHub Release ${release_tag} already exists; refusing to reuse it outside the resume path" + exit 1 + fi + else + gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ + --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + fi diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts index f946e1d4a92..2c97de39d44 100644 --- a/desktop/scripts/collect-release-assets.ts +++ b/desktop/scripts/collect-release-assets.ts @@ -11,13 +11,13 @@ import { join, resolve } from "node:path"; type BundleKind = "dmg" | "app.tar.gz" | "msi" | "appimage" | "deb"; -interface BundleSpec { +export interface BundleSpec { kind: BundleKind; dir: string; name: string; } -const bundlesByTarget: Record = { +export const bundlesByTarget: Record = { "universal-apple-darwin": [ { kind: "dmg", dir: "dmg", name: "macos.dmg" }, { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, diff --git a/desktop/scripts/updater-manifest.ts b/desktop/scripts/updater-manifest.ts index b21392fa113..e3fae9a372c 100644 --- a/desktop/scripts/updater-manifest.ts +++ b/desktop/scripts/updater-manifest.ts @@ -27,7 +27,7 @@ export interface UpdaterManifest { platforms: Record; } -const platformFiles: Record = { +export const platformFiles: Record = { "darwin-aarch64": "macos.app.tar.gz", "darwin-x86_64": "macos.app.tar.gz", "windows-x86_64": "windows-x64.msi", diff --git a/desktop/scripts/verify-release-assets.ts b/desktop/scripts/verify-release-assets.ts new file mode 100644 index 00000000000..7292be7a684 --- /dev/null +++ b/desktop/scripts/verify-release-assets.ts @@ -0,0 +1,343 @@ +/** + * Pre-publication release asset verification. + * + * Everything a release will publish is checked here, in the verify-release job, + * before any publication step may run: the expected platform file set derived from + * the workflow's own packaging matrices and the producer scripts' tables, every + * recorded checksum against the bytes on disk, every updater signature + * cryptographically against the pinned minisign public key, and the updater + * manifest parsed back against the files it names. The result is a + * machine-readable receipt; attach-release requires the receipt to name the same + * version and commit before it uploads anything, so publication can only ever + * consume the verified bundle. + */ +import { createHash, createPublicKey, verify as ed25519Verify, type KeyObject } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + standaloneArchiveName, + standaloneTargets as sharedStandaloneTargets, +} from "../../scripts/standalone-targets"; +import { bundlesByTarget } from "./collect-release-assets"; +import { platformFiles, writeUpdaterManifest, type UpdaterManifest } from "./updater-manifest"; + +export interface VerifyReleaseAssetsOptions { + version: string; + dir: string; + repo: string; + sha: string; + repoRoot?: string; + manifestOut?: string; + receiptOut?: string; + requireSignatures?: boolean; +} + +export interface ReleaseVerificationReceipt { + version: string; + repo: string; + sha: string; + expectedFiles: number; + checksumsVerified: number; + signaturesVerified: number; + manifestPlatforms: string[]; +} + +/** + * The expected file set, derived from the producer tables rather than restated. + * Signatures are required only for the assets the updater actually signs — the + * unique suffixes in platformFiles — because the DMG and the deb are not updater + * targets and are never signed. + */ +export function expectedReleaseAssets(options: { + version: string; + desktopTargets: string[]; + requireSignatures?: boolean; +}): string[] { + const expected: string[] = []; + for (const target of sharedStandaloneTargets) { + const archive = standaloneArchiveName(options.version, target); + expected.push(archive, `${archive}.sha256`); + } + const updaterSuffixes = new Set(Object.values(platformFiles)); + for (const target of options.desktopTargets) { + const bundles = bundlesByTarget[target]; + if (!bundles) throw new Error(`Unsupported desktop target in release matrix: ${target}`); + for (const bundle of bundles) { + const asset = `OpenCodex-${options.version}-${bundle.name}`; + expected.push(asset, `${asset}.sha256`); + if (options.requireSignatures && updaterSuffixes.has(bundle.name)) { + expected.push(`${asset}.sig`); + } + } + } + return expected; +} + +/** The packaging matrices of the release workflow itself — the source of truth for the set. */ +export function releaseMatrixTargets(workflowText: string): { + standaloneTargets: string[]; + desktopTargets: string[]; +} { + const workflow = Bun.YAML.parse(workflowText) as { + jobs?: Record } } }>; + }; + const read = (job: string): string[] => + (workflow.jobs?.[job]?.strategy?.matrix?.include ?? []) + .map(entry => entry.target) + .filter((target): target is string => typeof target === "string"); + const standaloneTargets = read("package-standalone"); + const desktopTargets = read("package-desktop"); + if (standaloneTargets.length === 0 || desktopTargets.length === 0) { + throw new Error("release.yml packaging matrices are empty or unreadable"); + } + return { standaloneTargets, desktopTargets }; +} + +/** + * Every recorded checksum against the bytes on disk, in exactly the producers' + * format (64 hex, two spaces, bare name, one trailing newline). The recorded name + * must equal the checksum file's own name minus the suffix: a foo.sha256 naming + * bar would leave foo's bytes unchecked while bar's are checked twice. + */ +export function verifyChecksums(dir: string): number { + const checksumFiles = readdirSync(dir).filter(name => name.endsWith(".sha256")).sort(); + if (checksumFiles.length === 0) throw new Error(`No .sha256 files found in ${dir}`); + for (const checksumFile of checksumFiles) { + const content = readFileSync(join(dir, checksumFile), "utf8"); + const match = /^([0-9a-f]{64}) (\S+)\n$/.exec(content); + if (!match) throw new Error(`Malformed checksum record in ${checksumFile}: ${JSON.stringify(content)}`); + const digest = match[1]!; + const recorded = match[2]!; + const own = checksumFile.slice(0, -".sha256".length); + if (recorded !== own) { + throw new Error(`Checksum ${checksumFile} records ${recorded}; it must record its own payload ${own}`); + } + const payload = join(dir, recorded); + if (!existsSync(payload)) throw new Error(`Checksum ${checksumFile} names ${recorded}, which is missing`); + const actual = createHash("sha256").update(readFileSync(payload)).digest("hex"); + if (actual !== digest) { + throw new Error(`Checksum mismatch for ${recorded}: recorded ${digest}, computed ${actual}`); + } + } + return checksumFiles.length; +} + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export interface MinisignPublicKey { + keyId: string; + publicKey: KeyObject; +} + +function minisignPayload(text: string, expectedBytes: number, what: string): Buffer { + const encoded = text + .split("\n") + .filter(line => line.trim().length > 0 && !line.trimStart().startsWith("untrusted comment:")) + .join("") + .trim(); + const payload = Buffer.from(encoded, "base64"); + if (payload.length !== expectedBytes) { + throw new Error(`Malformed ${what}: expected ${expectedBytes} decoded bytes, got ${payload.length}`); + } + return payload; +} + +/** minisign public key: base64 of algorithm ("Ed") || key id (8) || raw key (32). */ +export function parseMinisignPublicKey(text: string): MinisignPublicKey { + const payload = minisignPayload(text, 42, "minisign public key"); + const algorithm = payload.subarray(0, 2).toString("utf8"); + if (algorithm !== "Ed") { + throw new Error(`Unsupported minisign public key algorithm: ${JSON.stringify(algorithm)}`); + } + return { + keyId: payload.subarray(2, 10).toString("hex"), + publicKey: createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, payload.subarray(10, 42)]), + format: "der", + type: "spki", + }), + }; +} + +/** The updater public key pinned in the Tauri configuration. */ +export function loadUpdaterPublicKey(tauriConfPath: string): MinisignPublicKey { + const conf = JSON.parse(readFileSync(tauriConfPath, "utf8")) as { + plugins?: { updater?: { pubkey?: string } }; + }; + const pubkey = conf.plugins?.updater?.pubkey; + if (!pubkey) throw new Error(`No plugins.updater.pubkey in ${tauriConfPath}`); + return parseMinisignPublicKey(Buffer.from(pubkey, "base64").toString("utf8")); +} + +/** + * minisign signature: base64 of algorithm || key id (8) || signature (64). + * "Ed" is a pure Ed25519 signature over the raw file bytes — the form the Tauri + * bundler emits. "ED" (BLAKE2b-prehashed) or anything else fails loudly rather + * than being silently mis-verified. + */ +export function verifyUpdaterSignature(filePath: string, key: MinisignPublicKey): void { + const signaturePath = `${filePath}.sig`; + if (!existsSync(signaturePath)) throw new Error(`Missing signature: ${signaturePath}`); + const payload = minisignPayload(readFileSync(signaturePath, "utf8"), 74, `signature ${signaturePath}`); + const algorithm = payload.subarray(0, 2).toString("utf8"); + if (algorithm !== "Ed") { + throw new Error(`Unsupported signature algorithm in ${signaturePath}: ${JSON.stringify(algorithm)}`); + } + const keyId = payload.subarray(2, 10).toString("hex"); + if (keyId !== key.keyId) { + throw new Error(`Signature ${signaturePath} was made by key ${keyId}, not the pinned updater key ${key.keyId}`); + } + if (!ed25519Verify(null, readFileSync(filePath), key.publicKey, payload.subarray(10, 74))) { + throw new Error(`Signature verification failed for ${filePath}`); + } +} + +function parseBackManifest(manifestPath: string, options: VerifyReleaseAssetsOptions): string[] { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as UpdaterManifest; + if (manifest.version !== options.version) { + throw new Error(`Manifest version ${manifest.version} != ${options.version}`); + } + const platforms = Object.keys(manifest.platforms).sort(); + const expectedPlatforms = Object.keys(platformFiles).sort(); + if (JSON.stringify(platforms) !== JSON.stringify(expectedPlatforms)) { + throw new Error( + `Manifest platforms (${platforms.join(", ")}) do not match the updater platform set (${expectedPlatforms.join(", ")})`, + ); + } + for (const [platform, entry] of Object.entries(manifest.platforms)) { + const base = `OpenCodex-${options.version}-${platformFiles[platform]}`; + const expectedUrl = `https://github.com/${options.repo}/releases/download/v${options.version}/${base}`; + if (entry.url !== expectedUrl) { + throw new Error(`Manifest entry ${platform} points at ${entry.url}, expected ${expectedUrl}`); + } + if (!existsSync(join(options.dir, base))) { + throw new Error(`Manifest entry ${platform} names ${base}, which is missing`); + } + // The manifest must carry exactly the signature that was just verified, + // not merely a nonempty string. + const sidecar = readFileSync(join(options.dir, `${base}.sig`), "utf8").trim(); + if (entry.signature !== sidecar) { + throw new Error(`Manifest entry ${platform} signature does not match ${base}.sig`); + } + } + return platforms; +} + +function atomicWrite(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, content); + renameSync(temporary, path); +} + +export function verifyReleaseAssets(options: VerifyReleaseAssetsOptions): ReleaseVerificationReceipt { + const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); + const dir = resolve(options.dir); + const { standaloneTargets, desktopTargets } = releaseMatrixTargets( + readFileSync(join(repoRoot, ".github", "workflows", "release.yml"), "utf8"), + ); + // The workflow matrix must describe exactly the shared target set the builder + // uses; a target added to one and not the other fails here, not at release time. + const workflowStandalone = [...standaloneTargets].sort(); + const sharedStandalone = [...sharedStandaloneTargets].sort(); + if (JSON.stringify(workflowStandalone) !== JSON.stringify(sharedStandalone)) { + throw new Error( + `release.yml package-standalone matrix (${workflowStandalone.join(", ")})` + + ` does not match scripts/standalone-targets.ts (${sharedStandalone.join(", ")})`, + ); + } + const expected = expectedReleaseAssets({ + version: options.version, + desktopTargets, + requireSignatures: options.requireSignatures, + }); + const missing = expected.filter(name => !existsSync(join(dir, name))); + if (missing.length > 0) { + throw new Error(`Missing expected release assets:\n${missing.join("\n")}`); + } + + const checksumsVerified = verifyChecksums(dir); + + const updaterKey = loadUpdaterPublicKey( + join(repoRoot, "desktop", "src-tauri", "tauri.conf.json"), + ); + // Every signature present is verified, required or not: a tampered signature in + // an unsigned dry-run bundle must fail, not be skipped. + let signaturesVerified = 0; + for (const name of readdirSync(dir).filter(candidate => candidate.endsWith(".sig")).sort()) { + const payload = join(dir, name.slice(0, -".sig".length)); + if (!existsSync(payload)) throw new Error(`Signature ${name} has no payload beside it`); + verifyUpdaterSignature(payload, updaterKey); + signaturesVerified += 1; + } + + let manifestPlatforms: string[] = []; + if (options.manifestOut) { + writeUpdaterManifest({ + version: options.version, + dir, + repo: options.repo, + out: options.manifestOut, + requireAll: options.requireSignatures, + }); + manifestPlatforms = parseBackManifest(options.manifestOut, options); + } + + // attach-release uploads dist/release/* verbatim, so anything unexpected here + // would be published unchecked. The bundle is exactly the expected set plus + // the manifest this run just generated. + const allowed = new Set(expected); + if (options.manifestOut) allowed.add(options.manifestOut.split(/[\\/]/).pop()!); + const extras = readdirSync(dir).filter(name => !allowed.has(name)); + if (extras.length > 0) { + throw new Error(`Unexpected files in the release bundle (refusing to publish them):\n${extras.join("\n")}`); + } + + const receipt: ReleaseVerificationReceipt = { + version: options.version, + repo: options.repo, + sha: options.sha, + expectedFiles: expected.length, + checksumsVerified, + signaturesVerified, + manifestPlatforms, + }; + if (options.receiptOut) { + atomicWrite(options.receiptOut, `${JSON.stringify(receipt, null, 2)}\n`); + } + return receipt; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const dir = argument("--dir"); + const repo = argument("--repo"); + const sha = argument("--sha"); + if (!version || !dir || !repo || !sha) { + throw new Error( + "Usage: verify-release-assets.ts --version --dir --repo --sha " + + " [--manifest-out ] [--require-signatures] [--receipt-out ]", + ); + } + const receipt = verifyReleaseAssets({ + version, + dir, + repo, + sha, + manifestOut: argument("--manifest-out"), + receiptOut: argument("--receipt-out"), + requireSignatures: Bun.argv.includes("--require-signatures"), + }); + console.log( + `Verified ${receipt.expectedFiles} expected files, ${receipt.checksumsVerified} checksums,` + + ` ${receipt.signaturesVerified} signatures` + + (receipt.manifestPlatforms.length > 0 + ? `, manifest platforms: ${receipt.manifestPlatforms.join(", ")}` + : ""), + ); +} diff --git a/scripts/build-standalone.ts b/scripts/build-standalone.ts index 7b71df0bc43..f75fcc32251 100644 --- a/scripts/build-standalone.ts +++ b/scripts/build-standalone.ts @@ -1,14 +1,7 @@ import { createHash } from "node:crypto"; import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; - -const targets = new Set([ - "bun-darwin-arm64", - "bun-darwin-x64", - "bun-windows-x64", - "bun-linux-x64", - "bun-linux-arm64", -]); +import { isStandaloneTarget, standaloneExecutableName } from "./standalone-targets"; function hostTarget(): string { const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux"; @@ -22,7 +15,7 @@ function argumentValue(name: string): string | undefined { } const target = argumentValue("--target") ?? hostTarget(); -if (!targets.has(target)) { +if (!isStandaloneTarget(target)) { throw new Error(`Unsupported standalone target: ${target}`); } @@ -34,7 +27,7 @@ if (!existsSync(join(guiDist, "index.html"))) { const output = resolve(argumentValue("--out") ?? join(repoRoot, "dist", "standalone", target)); mkdirSync(output, { recursive: true }); -const executable = join(output, target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"); +const executable = join(output, standaloneExecutableName(target)); const result = Bun.spawnSync([ process.execPath, "build", diff --git a/scripts/standalone-targets.ts b/scripts/standalone-targets.ts new file mode 100644 index 00000000000..27b9a635b1e --- /dev/null +++ b/scripts/standalone-targets.ts @@ -0,0 +1,29 @@ +/** + * Standalone binary target metadata — the single source for the standalone build + * matrix. scripts/build-standalone.ts builds from this list, the release workflow's + * package-standalone matrix must stay equal to it, and the pre-publication + * verifier derives its expected standalone assets from it. + */ +export const standaloneTargets = [ + "bun-darwin-arm64", + "bun-darwin-x64", + "bun-windows-x64", + "bun-linux-x64", + "bun-linux-arm64", +] as const; + +export function isStandaloneTarget(value: string): boolean { + return (standaloneTargets as readonly string[]).includes(value); +} + +export function standaloneExecutableName(target: string): string { + return target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"; +} + +export function standaloneArchiveExtension(target: string): string { + return target.startsWith("bun-windows-") ? "zip" : "tar.gz"; +} + +export function standaloneArchiveName(version: string, target: string): string { + return `ocx-${version}-${target}.${standaloneArchiveExtension(target)}`; +} diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 29408205c78..bc93235d22d 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -180,10 +180,13 @@ marker, which the GUI detects to identify the shell without using IPC. The release workflow packages the desktop shell as `OpenCodex--macos.dmg`, `OpenCodex--windows-x64.msi`, `OpenCodex--linux-x86_64.AppImage`, and `OpenCodex--linux-amd64.deb`. Each artifact is collected with a `.sha256` file; -signed updater artifacts also carry `.sig` files. A release attachment job combines the -standalone and desktop assets, verifies checksums, and writes `latest.json` only when the -updater key secret is configured; it then requires all four platforms to have updater -signatures. +signed updater artifacts also carry `.sig` files. A pre-publication verification job +combines the standalone and desktop assets, derives the expected file set from the +packaging matrices, verifies every checksum and every updater signature, and writes +`latest.json` only when the updater key secret is configured, requiring all four +platforms to have updater signatures. Publication waits for that verification, and the +attachment job uploads the verified bundle only after the verification receipt names +the same version and commit. On macOS, in-app updates download `OpenCodex--macos.app.tar.gz`; the DMG is for the first installation. diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 2b79f6b4cc8..fba748aec7d 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -843,9 +843,10 @@ describe("GitHub Actions hardening", () => { contents: "read", }); - // Publication is the irreversible public act, so it waits for both packaging jobs; - // the full ordering contract is in tests/ci-workflows/release-pipeline-contract.test.ts. - expect(release.jobs?.publish?.needs).toEqual(["validate-dispatch", "package-standalone", "package-desktop"]); + // Publication is the irreversible public act, so it waits for the pre-publication + // verification of everything it will publish; the full ordering contract is in + // tests/ci-workflows/release-pipeline-contract.test.ts. + expect(release.jobs?.publish?.needs).toEqual(["validate-dispatch", "verify-release"]); expect(release.jobs?.publish?.["runs-on"]).toBe("ubuntu-latest"); expect(release.jobs?.publish?.permissions).toEqual({ contents: "write", @@ -5583,8 +5584,10 @@ test.skipIf(process.platform === "win32")("release shell recovers only unverifie const script = prelude + (scenario.mode === "missing-receipt" ? "" : publish) + '\n' + (scenario.dry ? "" : `PUBLISHED=$(sed -n 's/^published=//p' "$GITHUB_OUTPUT")\n${smoke}`); const child = Bun.spawn(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], { + // RESUME mirrors the workflow, where the env always defines it; the + // non-resume branches are what every scenario here exercises. env: { ...process.env, SCENARIO: scenario.mode, DRY_RUN: String(scenario.dry), - NPM_DIST_TAG: "latest", RELEASE_VERSION: "9.8.7", GITHUB_OUTPUT: output, + NPM_DIST_TAG: "latest", RELEASE_VERSION: "9.8.7", RESUME: "false", GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, CALLS: calls, COUNTER: join(dir, "counter") }, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 18b37d41793..80a2a814cc8 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -1,9 +1,19 @@ import { describe, expect, test } from "bun:test"; +import { createHash, generateKeyPairSync, sign as ed25519Sign } from "node:crypto"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; +import { standaloneTargets } from "../../scripts/standalone-targets"; +import { + expectedReleaseAssets, + parseMinisignPublicKey, + releaseMatrixTargets, + verifyChecksums, + verifyReleaseAssets, + verifyUpdaterSignature, +} from "../../desktop/scripts/verify-release-assets"; import { repoPath } from "../helpers/repo-root"; function temporaryDirectory(): string { @@ -313,3 +323,233 @@ describe("widget extension signing", () => { .toBeLessThan(script.indexOf("swift build")); }); }); + +/** + * The pre-publication verifier is the authority the verify-release job runs before + * anything may publish. Its expected set is derived from the real release matrices + * and the producer tables, its signatures are real Ed25519 fixtures in minisign + * shape, and the receipt it writes is the one attach-release requires. + */ +describe("release asset verification", () => { + const VERSION = "2.61.0"; + + function writeAsset(dir: string, name: string, payload: Buffer): void { + const digest = createHash("sha256").update(payload).digest("hex"); + writeFileSync(join(dir, name), payload); + writeFileSync(join(dir, `${name}.sha256`), `${digest} ${name}\n`); + } + + function makeMinisignKeypair(keyIdHex: string): { + pubkeyText: string; + keyId: Buffer; + signPayload: (payload: Buffer) => string; + } { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const raw = Buffer.from(publicKey.export({ format: "der", type: "spki" })).subarray(-32); + const keyId = Buffer.from(keyIdHex, "hex"); + const pubkeyText = `untrusted comment: test public key\n${Buffer.concat([Buffer.from("Ed"), keyId, raw]).toString("base64")}\n`; + const signPayload = (payload: Buffer): string => + `untrusted comment: test signature\n${Buffer.concat([Buffer.from("Ed"), keyId, ed25519Sign(null, payload, privateKey)]).toString("base64")}\n`; + return { pubkeyText, keyId, signPayload }; + } + + test("derives the expected set from the real release matrices and producer tables", () => { + const workflow = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); + const { standaloneTargets: workflowStandalone, desktopTargets } = releaseMatrixTargets(workflow); + // The workflow matrix and the builder's shared target set must agree exactly. + expect([...workflowStandalone].sort()).toEqual([...standaloneTargets].sort()); + expect(desktopTargets).toHaveLength(3); + + const expected = expectedReleaseAssets({ + version: VERSION, + desktopTargets, + requireSignatures: true, + }); + for (const name of [ + `ocx-${VERSION}-bun-windows-x64.zip`, + `ocx-${VERSION}-bun-linux-x64.tar.gz`, + `ocx-${VERSION}-bun-darwin-arm64.tar.gz.sha256`, + `OpenCodex-${VERSION}-macos.dmg`, + `OpenCodex-${VERSION}-macos.app.tar.gz.sig`, + `OpenCodex-${VERSION}-windows-x64.msi`, + `OpenCodex-${VERSION}-linux-x86_64.AppImage`, + `OpenCodex-${VERSION}-linux-amd64.deb`, + ]) { + expect(expected).toContain(name); + } + // Only the updater targets carry signatures; the DMG and the deb never do. + expect(expected).not.toContain(`OpenCodex-${VERSION}-macos.dmg.sig`); + expect(expected).not.toContain(`OpenCodex-${VERSION}-linux-amd64.deb.sig`); + expect(expected.some(name => name.includes("/"))).toBe(false); + }); + + test("verifies every recorded checksum and refuses a directory-prefixed record", () => { + const dir = temporaryDirectory(); + try { + writeAsset(dir, "ocx-1.0.0-bun-linux-x64.tar.gz", Buffer.from("payload")); + expect(verifyChecksums(dir)).toBe(1); + + const digest = createHash("sha256").update(Buffer.from("payload")).digest("hex"); + writeFileSync(join(dir, "bad.sha256"), `${digest} ocx-1.0.0-bun-linux-x64.tar.gz\n`); + expect(() => verifyChecksums(dir)).toThrow(/must record its own payload/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("rejects a tampered payload and a missing payload", () => { + const dir = temporaryDirectory(); + try { + writeAsset(dir, "ocx-1.0.0-bun-linux-x64.tar.gz", Buffer.from("payload")); + writeFileSync(join(dir, "ocx-1.0.0-bun-linux-x64.tar.gz"), Buffer.from("tampered")); + expect(() => verifyChecksums(dir)).toThrow(/Checksum mismatch/); + + rmSync(join(dir, "ocx-1.0.0-bun-linux-x64.tar.gz")); + expect(() => verifyChecksums(dir)).toThrow(/which is missing/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("verifies updater signatures against the pinned key and refuses lookalikes", () => { + const dir = temporaryDirectory(); + try { + const { pubkeyText, signPayload } = makeMinisignKeypair("0123456789abcdef"); + const key = parseMinisignPublicKey(pubkeyText); + const payload = Buffer.from("signed payload bytes"); + const asset = join(dir, "OpenCodex-1.0.0-macos.app.tar.gz"); + writeFileSync(asset, payload); + writeFileSync(`${asset}.sig`, signPayload(payload)); + expect(() => verifyUpdaterSignature(asset, key)).not.toThrow(); + + writeFileSync(asset, Buffer.from("tampered payload")); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/); + writeFileSync(asset, payload); + + const other = makeMinisignKeypair("fedcba9876543210"); + writeFileSync(`${asset}.sig`, other.signPayload(payload)); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/not the pinned updater key/); + + const hashed = `untrusted comment: test\n${Buffer.concat([Buffer.from("ED"), other.keyId, Buffer.alloc(64)]).toString("base64")}\n`; + writeFileSync(`${asset}.sig`, hashed); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Unsupported signature algorithm/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("runs the full pre-publication verification and writes the receipt", () => { + const root = temporaryDirectory(); + try { + const { pubkeyText, signPayload } = makeMinisignKeypair("0123456789abcdef"); + // The verifier reads the matrices and the pinned key from the repo root, so the + // scratch root gets the real workflow and a conf carrying the fixture key. + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(root, ".github", "workflows", "release.yml"), + readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"), + ); + mkdirSync(join(root, "desktop", "src-tauri"), { recursive: true }); + writeFileSync( + join(root, "desktop", "src-tauri", "tauri.conf.json"), + JSON.stringify({ plugins: { updater: { pubkey: Buffer.from(pubkeyText, "utf8").toString("base64") } } }), + ); + + const dir = join(root, "dist", "release"); + mkdirSync(dir, { recursive: true }); + // The fixture is an independent producer oracle, written out by hand: five + // standalone archives, five desktop bundles, and signatures on exactly the + // three updater targets. Building it with the function under test would hide + // an omission in the expected set. + const produced = [ + `ocx-${VERSION}-bun-darwin-arm64.tar.gz`, + `ocx-${VERSION}-bun-darwin-x64.tar.gz`, + `ocx-${VERSION}-bun-windows-x64.zip`, + `ocx-${VERSION}-bun-linux-x64.tar.gz`, + `ocx-${VERSION}-bun-linux-arm64.tar.gz`, + `OpenCodex-${VERSION}-macos.dmg`, + `OpenCodex-${VERSION}-macos.app.tar.gz`, + `OpenCodex-${VERSION}-windows-x64.msi`, + `OpenCodex-${VERSION}-linux-x86_64.AppImage`, + `OpenCodex-${VERSION}-linux-amd64.deb`, + ]; + const signed = new Set([ + `OpenCodex-${VERSION}-macos.app.tar.gz`, + `OpenCodex-${VERSION}-windows-x64.msi`, + `OpenCodex-${VERSION}-linux-x86_64.AppImage`, + ]); + for (const name of produced) { + writeAsset(dir, name, Buffer.from(`payload:${name}`)); + if (signed.has(name)) { + writeFileSync(join(dir, `${name}.sig`), signPayload(readFileSync(join(dir, name)))); + } + } + + // The derivation is checked against the oracle, not trusted: the expected set + // must be exactly the produced payloads plus their companions. + const expected = expectedReleaseAssets({ + version: VERSION, + desktopTargets: releaseMatrixTargets( + readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8"), + ).desktopTargets, + requireSignatures: true, + }); + const oracle = produced.flatMap(name => + signed.has(name) ? [name, `${name}.sha256`, `${name}.sig`] : [name, `${name}.sha256`]); + expect([...expected].sort()).toEqual([...oracle].sort()); + + const receiptPath = join(root, "verification", "receipt.json"); + const manifestPath = join(dir, "latest.json"); + const receipt = verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + manifestOut: manifestPath, + receiptOut: receiptPath, + requireSignatures: true, + }); + + expect(receipt.expectedFiles).toBe(expected.length); + expect(receipt.checksumsVerified) + .toBe(produced.length); + expect(receipt.signaturesVerified).toBe(signed.size); + expect(receipt.manifestPlatforms).toEqual([ + "darwin-aarch64", "darwin-x86_64", "linux-x86_64", "windows-x86_64", + ]); + expect(JSON.parse(readFileSync(receiptPath, "utf8"))).toEqual(receipt); + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + platforms: Record; + }; + expect(manifest.platforms["linux-x86_64"]!.url) + .toBe(`https://github.com/lidge-jun/opencodex/releases/download/v${VERSION}/OpenCodex-${VERSION}-linux-x86_64.AppImage`); + + // Anything beyond the expected set is refused rather than published. + writeFileSync(join(dir, "stray.txt"), "stray"); + expect(() => verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + manifestOut: manifestPath, + requireSignatures: true, + })).toThrow(/Unexpected files/); + rmSync(join(dir, "stray.txt")); + + rmSync(join(dir, `OpenCodex-${VERSION}-windows-x64.msi`)); + expect(() => verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + requireSignatures: true, + })).toThrow(/Missing expected release assets/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index 8f8ff47875a..208b1d962c0 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -7,11 +7,13 @@ type WorkflowStep = { if?: string; uses?: string; with?: Record; + env?: Record; run?: string; shell?: string; }; type WorkflowJob = { + if?: string; needs?: string[]; strategy?: { matrix?: { include?: Array<{ os?: string }> } }; steps?: WorkflowStep[]; @@ -95,37 +97,90 @@ describe("release pipeline contract", () => { // The bare names above only resolve end to end if the step checksums from the directory // the artifact lives in (it leaves the per-target build directory first), if the upload - // glob picks the checksum file up, and if the download flattens every artifact beside - // the verifier. Locking only the final shasum line would leave those joints unguarded. - // YAML block scalars are dedented on parse, so the script's own lines carry no - // indentation here. + // glob picks the checksum file up, and if the pre-publication verifier downloads every + // artifact flattened beside them. Locking only the final line would leave those joints + // unguarded. YAML block scalars are dedented on parse, so the script's own lines carry + // no indentation here. expect(archive!.run).toMatch(/^ *cd \.\.\/\.\.$/m); const upload = release.jobs?.["package-standalone"]?.steps ?.find(candidate => candidate.uses?.startsWith("actions/upload-artifact@")); expect(String(upload?.with?.path)).toContain("dist/ocx-*.sha256"); - const download = release.jobs?.["attach-release"]?.steps + const download = release.jobs?.["verify-release"]?.steps ?.find(candidate => candidate.uses?.startsWith("actions/download-artifact@") && candidate.with?.pattern === "standalone-*"); expect(download?.with?.["merge-multiple"]).toBe(true); expect(download?.with?.path).toBe("dist/release"); + }); - const verify = release.jobs?.["attach-release"]?.steps - ?.find(candidate => candidate.run?.includes("shasum")); + test("publication consumes the verified packaging result", () => { + const verify = release.jobs?.["verify-release"]; expect(verify).toBeDefined(); - expect(verify!.run).toContain("cd dist/release"); - expect(verify!.run).toContain("shasum -a 256 -c ./*.sha256"); - }); + expect(needsOf(verify).sort()) + .toEqual(["package-desktop", "package-standalone", "validate-dispatch"]); + // Verification is not a publication-mode step: a dry run must prove the same chain + // a real release relies on, so the job carries no dry-run exemption. + expect(verify!.if).toBeUndefined(); + expect(verify!.steps?.some(candidate => candidate.run?.includes("verify-release-assets.ts"))) + .toBe(true); - test("publication waits for both packaging jobs", () => { const publish = release.jobs?.publish; expect(publish).toBeDefined(); - expect(needsOf(publish).sort()) - .toEqual(["package-desktop", "package-standalone", "validate-dispatch"]); + expect(needsOf(publish).sort()).toEqual(["validate-dispatch", "verify-release"]); const attach = release.jobs?.["attach-release"]; expect(attach).toBeDefined(); - expect(needsOf(attach).sort()).toEqual(["package-desktop", "package-standalone", "publish"]); + expect(needsOf(attach).sort()).toEqual(["publish", "verify-release"]); + }); + + test("attach uploads only the verified bundle, and only after requiring its receipt", () => { + const steps = release.jobs?.["attach-release"]?.steps ?? []; + // Verification happens exactly once, before publication: attach must not re-verify + // checksums or regenerate the manifest from unverified parts. + expect(steps.some(candidate => candidate.run?.includes("shasum"))).toBe(false); + expect(steps.some(candidate => candidate.run?.includes("updater-manifest.ts"))).toBe(false); + + const bundle = steps.find(candidate => candidate.uses?.startsWith("actions/download-artifact@") + && candidate.with?.name === "verified-release"); + expect(bundle?.with?.path).toBe("dist/release"); + + const receiptCheck = steps.findIndex(candidate => candidate.run?.includes("verification/receipt.json")); + const upload = steps.findIndex(candidate => candidate.run?.includes("gh release upload")); + expect(receiptCheck).toBeGreaterThanOrEqual(0); + expect(upload).toBeGreaterThan(receiptCheck); + }); + + test("a partial publication has a recorded, explicit recovery path", () => { + const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); + // The only way npm publish is ever skipped: an explicit recovery input, requiring + // the version to already be acknowledged on npm, refusing combination with dry-run. + expect(releaseText).toContain("resume-after-npm-publish:"); + + const publishSteps = release.jobs?.publish?.steps ?? []; + const preflight = publishSteps.find(candidate => candidate.name === "Preflight release metadata"); + expect(preflight?.run).toContain("no acknowledged publication to resume from"); + expect(preflight?.run).toContain("cannot combine with dry-run"); + + const publication = publishSteps.find(candidate => candidate.id === "publication"); + // The summary line references RELEASE_VERSION under set -u; the env must carry it. + expect(publication?.env?.RELEASE_VERSION).toBe("${{ inputs.version }}"); + expect(publication?.run).toContain('if [ "$RESUME" = "true" ]'); + expect(publication?.run).toContain('echo "published=true" >> "$GITHUB_OUTPUT"'); + // A successful publish records the recovery path at the moment it matters. + expect(publication?.run).toContain("never republish this version"); + + // The version-line gate must let the resume path past a tag it created itself. + const versionLine = publishSteps.find(candidate => candidate.run?.includes("assert-releasable")); + expect(versionLine?.env?.RESUME).toBe("${{ inputs.resume-after-npm-publish }}"); + expect(versionLine?.run).toContain('$RESUME'); + + // A run that failed after the release was created must be able to complete the + // attachment on resume; outside resume, an existing release stays a hard failure. + const create = publishSteps.find(candidate => candidate.name === "Create GitHub release"); + expect(create?.env?.RESUME).toBe("${{ inputs.resume-after-npm-publish }}"); + expect(create?.run).toContain('gh release view "$release_tag"'); + expect(create?.run).toContain("already exists; reusing it for attachment"); + expect(create?.run).toContain("refusing to reuse it outside the resume path"); }); }); diff --git a/tests/gui/standalone-build-script.test.ts b/tests/gui/standalone-build-script.test.ts index 8e524ec704f..7dab243ae2e 100644 --- a/tests/gui/standalone-build-script.test.ts +++ b/tests/gui/standalone-build-script.test.ts @@ -2,15 +2,19 @@ import { expect, test } from "bun:test"; import { repoPath } from "../helpers/repo-root"; const script = await Bun.file(repoPath("scripts", "build-standalone.ts")).text(); +const targets = await Bun.file(repoPath("scripts", "standalone-targets.ts")).text(); test("standalone build script exposes supported targets and packaging contract", () => { + // The target list lives in the shared module the release verifier also reads; + // the build script consumes it rather than restating it. + expect(script).toContain("./standalone-targets"); for (const target of [ "bun-darwin-arm64", "bun-darwin-x64", "bun-windows-x64", "bun-linux-x64", "bun-linux-arm64", - ]) expect(script).toContain(target); + ]) expect(targets).toContain(target); expect(script).toContain("--compile"); expect(script).toContain("--outfile"); expect(script).toContain("gui/dist"); From a9fb95053d7bed8cdaeb5f6a35d62e816b60da5d Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:38:01 +0900 Subject: [PATCH 80/87] Harden durable runtime ownership against stale consent and unsafe replacement (#5406) * fix(service): make ownership state crash-safe and consent-bound Use one default-home authority with an active-home compatibility mirror, token/PID/process-instance locks, fsynced atomic replacement, mirror-first deletion, and authoritative recovery after partial commits. Bind ownership grants to the exact approved owner/install/generation/revision and to re-observed managing-CLI compatibility. OpenCodex 2.60.x, unknown managers, and registrations without protocol 1 remain guests. Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; the included regressions are for hosted CI. * fix(update): fence replacement and restart with ownership leases Split package replacement, runtime stop, and service restoration authority. Unknown and desktop ownership now block package replacement; Node and Bun read the same authoritative state observations. Hold a shared mutation lease from final subject and liveness validation through replacement, and through dashboard restart. Direct bind takes the same lease, while repair children join by an exact live token. Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; hosted CI is the verifier. * docs(structure): define authoritative ownership and takeover compatibility Record the authority/mirror commit protocol, consent subject precondition, managing-CLI compatibility floor, independent update authorities, and shared replacement/start lease. Local structure checks were NOT RUN by instruction; hosted CI is the verifier. * fix(service): recover incomplete locks without poisoning delegates Reclaim empty or partial state locks only after the stale grace and dead-PID proof. Canonical delegated mutation tokens are consumed from child environments, cached only while the exact parent lease remains live, and discarded before fresh acquisition. Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; hosted CI is the verifier. * fix(service): make lease cleanup intent explicit Keep token-specific stale recovery as the owner of uncertain descriptor, owner-file, directory, and release cleanup paths so deterministic hygiene accepts the deliberate best-effort boundaries. Local checks were NOT RUN by instruction. * fix(service): align typed evidence with shared record selector Cast the service-owned evidence union at the shared plain-ESM selector boundary; both carry the same validated record shape, while TypeScript correctly rejects the missing index signature without the explicit boundary cast. Local checks were NOT RUN by instruction; this fixes the exact hosted typecheck diagnostic. * test: isolate corrupt authority and follow shared state paths Reset the corrupt-authority fixture before exercising valid-authority mirror recovery, and point the updater source oracle at the shared active/default path resolver instead of an inlined filename literal. Local tests were NOT RUN by instruction; this fixes the exact hosted shard failures. * test(update): follow the reconciled install-state facade Point the Node launcher, Bun state reader, and source oracle at the install-state-contract surface landed on dev, while keeping one state-record authority implementation underneath it. Local checks were NOT RUN by instruction; this fixes the exact hosted shard diagnostic. --- bin/ocx.mjs | 100 ++-- scripts/test-layout/layout.json | 1 + src/cli/index.ts | 32 +- src/service.ts | 8 +- src/service/cli.ts | 11 +- src/service/install-state-contract.d.mts | 43 +- src/service/install-state-contract.mjs | 161 +---- src/service/orchestration.ts | 6 +- src/service/ownership-compatibility.ts | 164 ++++++ src/service/ownership-mutation-lease.d.mts | 23 + src/service/ownership-mutation-lease.mjs | 201 +++++++ src/service/repair.ts | 2 +- src/service/state-lock.ts | 269 +++++++++ src/service/state-record.d.mts | 36 ++ src/service/state-record.mjs | 138 +++++ src/service/state.ts | 549 +++++++++--------- src/update/index.ts | 126 ++-- src/update/job.ts | 17 +- src/update/restart-ownership.ts | 24 +- src/update/runtime-ownership.d.mts | 6 +- src/update/runtime-ownership.mjs | 46 +- structure/runtime.md | 99 ++-- tests/fixtures/test-layout-expected.json | 1 + .../service-ownership-compatibility.test.ts | 106 ++++ .../service-ownership-handover.test.ts | 13 +- tests/service/service-ownership-state.test.ts | 409 +++++++++---- tests/update/update-desktop-owner.test.ts | 166 ++---- tests/update/update-stop-first.test.ts | 15 +- 28 files changed, 1932 insertions(+), 840 deletions(-) create mode 100644 src/service/ownership-compatibility.ts create mode 100644 src/service/ownership-mutation-lease.d.mts create mode 100644 src/service/ownership-mutation-lease.mjs create mode 100644 src/service/state-lock.ts create mode 100644 src/service/state-record.d.mts create mode 100644 src/service/state-record.mjs create mode 100644 tests/service/service-ownership-compatibility.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 1603e370416..1e483f754ee 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -15,9 +15,10 @@ import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; import { planUpdateRuntimeHandling } from "../src/update/runtime-ownership.mjs"; import { inspectInstallStateBytes, - resolveOwnershipFromEvidence, + selectAuthoritativeServiceState, serviceStateFilesFor, } from "../src/service/install-state-contract.mjs"; +import { acquireOwnershipMutationLease } from "../src/service/ownership-mutation-lease.mjs"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -262,27 +263,40 @@ function runPackageManagerSelfUpdate(manager) { // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` // unloads it, so a successful update must refresh and restart it afterwards. - const serviceStateFiles = serviceStateFilesFor(configDir(), join(homedir(), ".opencodex")); - const serviceStatePath = serviceStateFiles[0]; - const serviceWasInstalled = existsSync(serviceStatePath); - /** - * What this update may do to the runtime, decided by the SAME contract the Bun updater - * uses — every state path, the whole record shape, and absence as the only answer that - * means no claim. - * - * This used to be a local reader that inspected the anchor alone and returned "known - * unowned" whenever the `ownership` field was simply missing, including from a record that - * fails the contract outright. A takeover the Bun updater refused to disturb was therefore - * fair game here, which is an authorization gap rather than a cosmetic divergence. - */ + const allServiceStatePaths = serviceStateFilesFor(configDir(), join(homedir(), ".opencodex")); + // The test guard's legacy path is the developer's real home. Production always reads the + // same active-home + default-home observations as the Bun resolver. + const serviceStatePaths = process.env.OCX_TEST_HOME_GUARD === "1" + ? allServiceStatePaths.slice(0, 1) + : allServiceStatePaths; + const serviceWasInstalled = serviceStatePaths.some(path => existsSync(path)); + // What this update may do to the runtime. The same rule the Bun updater applies, from the + // same module: a desktop takeover vetoes both the stop and the service refresh below. + const readServiceState = () => selectAuthoritativeServiceState( + serviceStatePaths.map(path => inspectInstallStateBytes(path, at => readFileSync(at, "utf8"))), + ); const readOwnership = () => { - const evidence = serviceStateFiles.map(path => inspectInstallStateBytes(path, at => readFileSync(at, "utf8"))); - const resolution = resolveOwnershipFromEvidence(evidence); - if (resolution.kind === "owned") return { ownership: resolution.ownership, ownershipUnknown: false }; - return { ownership: null, ownershipUnknown: resolution.kind === "unknown" }; + const selected = readServiceState(); + if (selected.kind === "unknown") return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; + if (selected.kind === "none") return { + ownership: null, ownershipUnknown: false, subjectToken: JSON.stringify(["none", selected.revision]), + }; + const ownership = selected.state.ownership ?? null; + return { + ownership, + ownershipUnknown: false, + subjectToken: JSON.stringify(ownership + ? ["owned", selected.revision, ownership] + : ["none", selected.revision]), + }; }; - let runtimePlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + const initialOwnership = readOwnership(); + const runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); if (runtimePlan.notice) console.log(runtimePlan.notice); + if (!runtimePlan.mayReplacePackage) { + console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + process.exit(1); + } const trayBeforeUpdate = planWindowsTrayUpdate( process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, ); @@ -296,10 +310,11 @@ function runPackageManagerSelfUpdate(manager) { } /** Register from scratch, preserving the recorded backend. Only for a genuinely absent service. */ function serviceInstallArgs() { - try { - const state = JSON.parse(readFileSync(serviceStatePath, "utf8")); - if (state.backend === "native") return [postUpdateLauncher, "service", "install", "--native"]; - } catch { /* missing or corrupt — fall through to default */ } + const selected = readServiceState(); + if (selected.kind === "unknown") throw new Error(`service backend is unknown: ${selected.reason}`); + if (selected.kind === "state" && selected.state.backend === "native") { + return [postUpdateLauncher, "service", "install", "--native"]; + } return [postUpdateLauncher, "service", "install"]; } /** @@ -450,7 +465,7 @@ function runPackageManagerSelfUpdate(manager) { // claim the runtime during an update that takes minutes, and the refusal that repair // just returned is indistinguishable from any other failure at this layer. const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true }); - if (!nowOwned.stopRuntime) { + if (!nowOwned.mayStopRuntime) { console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); return; } @@ -497,12 +512,13 @@ function runPackageManagerSelfUpdate(manager) { function recoverStoppedRuntimeAfterFailure() { // Nothing was stopped under a foreign owner, so there is nothing to recover — and // starting a proxy here would put a second one beside the runtime the app is managing. - if (!runtimePlan.stopRuntime) return; + const recoveryPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + if (!recoveryPlan.mayStopRuntime) return; if (!postUpdateLauncherUsable) { console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); return; } - if (runtimePlan.refreshService) { + if (recoveryPlan.mayRestoreService) { console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service."); refreshBackgroundServiceOrStartDirect(); } else if (hasRuntimeState) { @@ -518,15 +534,7 @@ function runPackageManagerSelfUpdate(manager) { // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides // whether the obligation is safe to finish. const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); - // Re-read at the point of action rather than trusting the plan formed above: the Windows - // tray handoff between them spawns children, so a takeover can land in the gap, and - // stopping a runtime that just changed hands is the failure this lane exists to prevent. - { - const atStop = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); - if (atStop.notice && atStop.notice !== runtimePlan.notice) console.log(atStop.notice); - runtimePlan = atStop; - } - if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)) { + if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)) { console.log("⏹ Stopping the running proxy before updating..."); const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true }); const stillHasRuntimeState = @@ -578,6 +586,22 @@ function runPackageManagerSelfUpdate(manager) { } } + const replacementLease = acquireOwnershipMutationLease(serviceStatePaths); + const replacementOwnership = readOwnership(); + const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); + const replacementLiveness = runtimePlan.mayStopRuntime + ? probeProxyLiveness(bakePort, bakeHostname) + : "dead"; + if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + || !replacementPlan.mayReplacePackage + || replacementLiveness !== "dead") { + replacementLease.release(); + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + console.error(replacementPlan.notice + ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); + process.exit(1); + } + // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a // content-addressable store and generated global shims, so its path uses pnpm's own // global update operation and verifies the active group instead of renaming files. @@ -666,6 +690,8 @@ function runPackageManagerSelfUpdate(manager) { `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); res = { status: 1 }; } + replacementLease.release(); + const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); repairCodexShimIfNeeded(postUpdateLauncher); @@ -681,10 +707,10 @@ function runPackageManagerSelfUpdate(manager) { } // The stop above unloaded any managed service; refresh via the freshly-installed // launcher so the new files write the baked paths and the service restarts. - if (runtimePlan.refreshService) { + if (postInstallPlan.mayRestoreService) { console.log("Refreshing the background service with the updated files..."); refreshBackgroundServiceOrStartDirect(); - } else if (runtimePlan.stopRuntime) { + } else if (postInstallPlan.mayStopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } process.exit(0); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 41daa0cd488..f79307b4633 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1379,6 +1379,7 @@ "server-xai-header-parity.test.ts": "server", "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", + "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", diff --git a/src/cli/index.ts b/src/cli/index.ts index b9fe221eb51..b98a28b1e78 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -94,7 +94,8 @@ import { summarizeStopRun, type StopOutcome, type StopRunRecord } from "./stop-r import { runCli } from "./root"; import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control"; import { startupDataPlaneToken } from "../lib/service-secrets"; -import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; +import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatePaths, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; +import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health"; import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env"; import { buildDesktop3pRegistry } from "../claude/desktop-3p"; @@ -448,6 +449,29 @@ async function handleStart(options: { block?: boolean } = {}) { // live daemon holding resources while it overwrites its own binary. await maybeShowUpdatePrompt(); + const startLease = acquireOwnershipMutationLease(serviceStatePaths()); + // The earlier probe owned journal cleanup. This one owns the bind decision: an updater may + // have stopped the old runtime and acquired the same lease before package replacement. + const fencedLive = await findLiveProxy(START_OWNERSHIP_LIVENESS); + if (fencedLive) { + const decision = decideStartWithLiveOwner({ + livePort: fencedLive.port, + requestedPort, + ocxService: process.env.OCX_SERVICE, + }); + if (decision === "service-stay-out") { + startLease.release(); + console.log(`Proxy already running (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}); service wrapper staying out of the way.`); + process.exit(0); + } + if (decision === "refuse") { + startLease.release(); + console.error(`⚠️ Proxy appeared before bind (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}). Use 'ocx stop' first.`); + process.exit(1); + } + siblingStart = true; + } + // Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries // the same port only (never hop — that was the remaining PR #152 gap). @@ -460,6 +484,7 @@ async function handleStart(options: { block?: boolean } = {}) { const readinessGate = createReadinessGate(); let server: ReturnType; const localAttestationSecret = createLocalAttestationSecret(); + try { for (let attempt = 0; ; attempt++) { try { server = startServer(port, { localAttestationSecret, readinessGate }); @@ -471,6 +496,7 @@ async function handleStart(options: { block?: boolean } = {}) { } catch (err) { if (err instanceof SpendLedgerOwnerError) { console.error(`❌ ${err.message}`); + startLease.release(); process.exit(1); } if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; @@ -480,6 +506,7 @@ async function handleStart(options: { block?: boolean } = {}) { const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 }); if (!freed) { console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`); + startLease.release(); process.exit(1); } continue; @@ -488,6 +515,9 @@ async function handleStart(options: { block?: boolean } = {}) { port = await chooseListenPort(requestedPort, { sibling: siblingStart }); } } + } finally { + startLease.release(); + } // A single request's streaming error must never crash the daemon serving every // other Codex session — capture the full stack to crash.log and stay up. installCrashGuards(); diff --git a/src/service.ts b/src/service.ts index ebeca0258cd..79cfff6efda 100644 --- a/src/service.ts +++ b/src/service.ts @@ -6,8 +6,12 @@ * restore it via the command. */ -export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceOwner, ServiceOwnership, ServiceOwnershipResolution, ServiceStateSwapDeps } from "./service/state"; -export { SERVICE_MANAGED_ENV, stableLauncherEntry, serviceLogPath, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, swapServiceInstallState, serviceOwnership, resolveServiceOwnership, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; +export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceStateResolution, ServiceOwner, ServiceOwnership, ServiceOwnershipSubject, ServiceOwnershipResolution, ServiceStateSwapDeps, RecordServiceOwnerRequest, RecordServiceOwnerDeps, ReleaseServiceOwnerDeps, RemoveServiceStateDeps } from "./service/state"; +export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; +export type { OwnershipMutationLeaseOptions, OwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; +export { acquireOwnershipMutationLease, withOwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; +export type { ManagingCliRole, ManagingCliObservation, RegisteredManagingCliInvocation, ServiceTakeoverCompatibilityInput, ServiceTakeoverCompatibility } from "./service/ownership-compatibility"; +export { registeredManagingCliInvocation, assessServiceTakeoverCompatibility, sameServiceTakeoverCompatibility } from "./service/ownership-compatibility"; export type { ServiceApiTokenOrigin, ProvisionedServiceApiToken } from "./service/guards"; export { ServiceOwnershipError, isServiceOwnershipError, serviceEnvironmentOwnedHere, assertServiceEnvironmentMatchesInstall, serviceRetryCommand, assertNotAdminToken, assertServiceAuthEnvironment, writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./service/guards"; export { resolveServiceListenPort, installedServiceListenPort, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, confirmServiceServing, reportServiceServing, resolvedProxyEnv } from "./service/health"; diff --git a/src/service/cli.ts b/src/service/cli.ts index 2dbce4599d3..1322b9948b4 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -234,6 +234,15 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise string): InstallStateEvidence; -export declare function resolveOwnershipFromEvidence( - evidence: readonly { path: string; kind: string; reason?: string; state?: unknown }[], -): OwnershipResolution; -export declare function serviceStateFilesFor(opencodexHomeDir: string, defaultHomeDir: string): string[]; +export type OwnershipResolution = + | { readonly kind: "none"; readonly revision: number; readonly needsRepair?: boolean } + | { readonly kind: "owned"; readonly ownership: { owner: string; installId: string; consentGeneration: number }; readonly revision: number } + | { readonly kind: "unknown"; readonly reason: string }; +export declare const SERVICE_STATE_FILE: "service-state.json"; +export declare function parseOwnershipClaim(value: unknown): import("./state-record.mjs").ServiceOwnershipRecord | null; +export declare function parseInstallStateRecord(value: unknown): import("./state-record.mjs").ServiceInstallStateRecord | null; +export declare function inspectInstallStateBytes(path: string, read: (path: string) => string): ServiceStateRecordEvidence; +export declare function resolveOwnershipFromEvidence(evidence: readonly ServiceStateRecordEvidence[]): OwnershipResolution; +export declare function serviceStateFilesFor(opencodexHomeDir: string, defaultHomeDir: string, platform?: NodeJS.Platform): string[]; diff --git a/src/service/install-state-contract.mjs b/src/service/install-state-contract.mjs index b07cb7cc901..b9baafa6186 100644 --- a/src/service/install-state-contract.mjs +++ b/src/service/install-state-contract.mjs @@ -1,141 +1,34 @@ -/** - * The service install-state contract, shared by both runtimes. - * - * `src/service/state.ts` is the authoritative reader and `bin/ocx.mjs` is the Node launcher - * that cannot import TypeScript. They used to validate the record separately, and the - * launcher's copy was weaker in two ways that mattered: it inspected only the anchor path, - * and it returned "known unowned" for any record whose `ownership` field was simply absent — - * including a record that fails the contract outright, such as one with no homes or an - * unsupported version. A takeover the Bun updater refused to disturb was therefore fair game - * for the npm and pnpm lane. - * - * This module is the one algorithm. Both sides import it, so the two lanes cannot answer the - * same question differently. - */ -import { join, resolve } from "node:path"; +/** Compatibility surface for the shared install-state contract landed before C2 hardening. */ +import { + inspectServiceStateRecords, + parseServiceInstallStateRecord, + parseServiceOwnershipRecord, + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, + serviceStatePathsForHomes, +} from "./state-record.mjs"; -function isNonNegativeInteger(value) { - return typeof value === "number" && Number.isInteger(value) && value >= 0; -} - -/** - * Validate an ownership claim read off disk. - * - * Returns the ORIGINAL object rather than a rebuilt one: a newer writer may carry fields - * this version does not know about, and rebuilding would drop them on the next preserve — - * the same lost-field failure the record exists to stop. - */ -export function parseOwnershipClaim(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - if (value.owner !== "cli" && value.owner !== "desktop") return null; - if (typeof value.installId !== "string" || value.installId.length === 0) return null; - if (!isNonNegativeInteger(value.consentGeneration)) return null; - return value; -} - -/** Validate a whole install record. Null means the bytes are not a record this tree wrote. */ -export function parseInstallStateRecord(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - if (value.version !== 1 && value.version !== 2) return null; - if (typeof value.codexHome !== "string" || value.codexHome.length === 0) return null; - if (typeof value.opencodexHome !== "string" || value.opencodexHome.length === 0) return null; - for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"]) { - if (value[key] !== undefined && (typeof value[key] !== "string" || value[key].length === 0)) return null; - } - // cliPath is the one path that is legitimately null: cliEntry() returns null for a - // standalone binary, and the writer stores it. - if (value.cliPath !== undefined && value.cliPath !== null - && (typeof value.cliPath !== "string" || value.cliPath.length === 0)) return null; - if (value.revision !== undefined && !isNonNegativeInteger(value.revision)) return null; - if (value.consentGenerationCeiling !== undefined && !isNonNegativeInteger(value.consentGenerationCeiling)) return null; - // A malformed ownership claim invalidates the whole record instead of being dropped: - // silently discarding it is precisely the demotion this field exists to prevent, and a - // reader that cannot trust the claim must not be told the runtime is unowned. - if (value.ownership !== undefined && parseOwnershipClaim(value.ownership) === null) return null; - if (value.version === 1) { - if (value.backend !== undefined) return null; - } else if (value.backend !== "scheduler" && value.backend !== "native") { - return null; - } - return value; -} +export const SERVICE_STATE_FILE = "service-state.json"; +export const parseOwnershipClaim = parseServiceOwnershipRecord; +export const parseInstallStateRecord = parseServiceInstallStateRecord; +export const serviceStateFilesFor = serviceStatePathsForHomes; +export { + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, +}; -/** - * Classify one state path's bytes. `read` returns the text, or throws; an ENOENT throw is - * absence and every other throw is a failure to ask. - * - * Absent, unreadable and invalid are three different answers. Collapsing them is how a - * locked-down or truncated record becomes permission to reactivate the npm launcher. - */ export function inspectInstallStateBytes(path, read) { - let raw; - try { - raw = read(path); - } catch (error) { - const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; - if (code === "ENOENT") return { path, kind: "absent" }; - return { path, kind: "unreadable", reason: code || String(error) }; - } - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return { path, kind: "invalid" }; - } - const state = parseInstallStateRecord(parsed); - return state ? { path, kind: "valid", state } : { path, kind: "invalid" }; + return inspectServiceStateRecords([path], read)[0]; } -/** - * What every state path, together, says about who owns the runtime. - * - * An unknown resolution is the answer that matters. A single null for "absent, unreadable or - * malformed" lets a caller read a permissions error as "the CLI owns it" and re-enable the - * npm launcher over a consented takeover. Absence is the only thing that may mean no claim. - */ export function resolveOwnershipFromEvidence(evidence) { - for (const entry of evidence) { - // Any path. A claim we are not allowed to look at is still a claim. - if (entry.kind === "unreadable") { - return { kind: "unknown", reason: `a service state path could not be read (${entry.reason})` }; - } - } - // Only the ANCHOR's corruption is fatal. The second path is the legacy default-home entry - // kept so an install made before OPENCODEX_HOME existed can still be found; unrelated junk - // left there by an old version must not be able to block every repair on this machine. - if (evidence[0] && evidence[0].kind === "invalid") { - return { kind: "unknown", reason: "the service install record is present but not valid" }; - } - const claims = []; - for (const entry of evidence) { - if (entry.kind === "valid" && entry.state.ownership) claims.push(entry.state.ownership); - } - const first = claims[0]; - if (first === undefined) return { kind: "none" }; - if (claims.some(claim => claim.owner !== first.owner || claim.installId !== first.installId)) { - return { kind: "unknown", reason: "the service state paths name different owners" }; - } - // Same claim in both places; the higher generation is the later write. - let best = first; - for (const claim of claims) if (claim.consentGeneration > best.consentGeneration) best = claim; - return { kind: "owned", ownership: best }; + const selected = selectAuthoritativeServiceState(evidence); + if (selected.kind === "unknown" || selected.kind === "none") return selected; + return selected.state.ownership + ? { kind: "owned", ownership: selected.state.ownership, revision: selected.revision } + : { kind: "none", revision: selected.revision }; } - -export const SERVICE_STATE_FILE = "service-state.json"; - -/** - * The state files to consult, in the order every reader resolves them: this OpenCodex home - * first, then the legacy default home kept for installs made before OPENCODEX_HOME existed. - * - * Shared so the launcher cannot inspect a shorter list than the authoritative reader — which - * it did, seeing only the anchor and never the legacy claim beside it. - */ -export function serviceStateFilesFor(opencodexHomeDir, defaultHomeDir) { - const anchor = join(opencodexHomeDir, SERVICE_STATE_FILE); - const legacy = join(defaultHomeDir, SERVICE_STATE_FILE); - const same = process.platform === "win32" - ? resolve(anchor).toLowerCase() === resolve(legacy).toLowerCase() - : resolve(anchor) === resolve(legacy); - return same ? [anchor] : [anchor, legacy]; -} - diff --git a/src/service/orchestration.ts b/src/service/orchestration.ts index 9a40ba2479e..92f374b3c34 100644 --- a/src/service/orchestration.ts +++ b/src/service/orchestration.ts @@ -11,7 +11,7 @@ import type { ServiceDiagnostic } from "./diagnostics"; import { assertServiceEnvironmentMatchesInstall } from "./guards"; import { runLaunchctl, launchdEvictionTargets, launchctlBootoutBenign, probeLaunchdLoadState, installLaunchd, startLaunchd, stopLaunchd, statusLaunchd, uninstallLaunchd } from "./launchd"; import { assertSchedulerRegistrationBeforeStart } from "./repair"; -import { SERVICE_MANAGED_ENV, TASK, plistPath, serviceStatePaths, writeServiceInstallState } from "./state"; +import { SERVICE_MANAGED_ENV, TASK, plistPath, removeServiceInstallStateRecords, writeServiceInstallState } from "./state"; import type { ServiceBackend } from "./state"; import { unitPath, isSystemd, installSystemd, startSystemd, stopSystemd, statusSystemd, uninstallSystemd, systemdServiceInstallCleanupOps } from "./systemd"; import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, installWindows, installWindowsNative, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops"; @@ -527,9 +527,7 @@ export function stopServiceIfInstalledDetailed(): ServiceStopOutcome { /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */ export function removeServiceInstallState(): void { - for (const path of serviceStatePaths()) { - try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ } - } + removeServiceInstallStateRecords(); } type UninstallServiceHooksForTests = { diff --git a/src/service/ownership-compatibility.ts b/src/service/ownership-compatibility.ts new file mode 100644 index 00000000000..fe089e44758 --- /dev/null +++ b/src/service/ownership-compatibility.ts @@ -0,0 +1,164 @@ +import { createHash } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { parseStrictSemver } from "../lib/strict-semver"; +import type { + ServiceInstallState, + ServiceOwnershipSubject, +} from "./state"; +import { + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, +} from "./install-state-contract.mjs"; + +export type ManagingCliRole = "service-registration" | "path"; + +export type ManagingCliObservation = + | { readonly status: "absent" } + | { readonly status: "unknown"; readonly reason: string } + | { readonly status: "observed"; readonly version: string; readonly identity: string }; + +export type RegisteredManagingCliInvocation = + | { readonly status: "absent" } + | { readonly status: "unknown"; readonly reason: string } + | { readonly status: "resolved"; readonly executable: string; readonly args: readonly string[] }; + +/** Resolve the exact command baked into the preserved service registration. */ +export function registeredManagingCliInvocation( + state: ServiceInstallState | null, +): RegisteredManagingCliInvocation { + if (!state) return { status: "absent" }; + if (state.launcherPath) { + return isAbsolute(state.launcherPath) + ? { status: "resolved", executable: state.launcherPath, args: [] } + : { status: "unknown", reason: "the recorded service launcher is not absolute" }; + } + if (!state.bunPath || !isAbsolute(state.bunPath)) { + return { status: "unknown", reason: "the registered service executable is missing or not absolute" }; + } + if (state.cliPath === null) return { status: "resolved", executable: state.bunPath, args: [] }; + if (typeof state.cliPath === "string" && isAbsolute(state.cliPath)) { + return { status: "resolved", executable: state.bunPath, args: [state.cliPath] }; + } + return { status: "unknown", reason: "the registered service CLI path is missing or not absolute" }; +} + +export interface ServiceTakeoverCompatibilityInput { + readonly state: ServiceInstallState | null; + readonly subject: ServiceOwnershipSubject; + readonly managers: Readonly>; +} + +export type ServiceTakeoverCompatibility = + | { + readonly kind: "supported"; + readonly protocolVersion: typeof SERVICE_OWNERSHIP_PROTOCOL_VERSION; + readonly minimumCliVersion: typeof SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION; + /** Opaque binding over the approved subject and both managing-CLI observations. */ + readonly token: string; + } + | { + readonly kind: "blocked"; + readonly reason: + | "managing-cli-unknown" + | "managing-cli-unsupported" + | "managing-cli-unobserved" + | "service-protocol-unsupported"; + readonly detail: string; + readonly minimumCliVersion: typeof SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION; + }; + +function comparePrerelease(left: readonly (bigint | string)[], right: readonly (bigint | string)[]): number { + if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1; + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const a = left[index]; + const b = right[index]; + if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1; + if (a === b) continue; + if (typeof a === "bigint" && typeof b === "bigint") return a < b ? -1 : 1; + if (typeof a === "bigint") return -1; + if (typeof b === "bigint") return 1; + return a < b ? -1 : 1; + } + return 0; +} + +function versionSupportsOwnership(value: string): boolean { + const actual = parseStrictSemver(value); + const minimum = parseStrictSemver(SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION); + if (!actual || !minimum) return false; + for (let index = 0; index < actual.core.length; index += 1) { + if (actual.core[index] !== minimum.core[index]) return actual.core[index]! > minimum.core[index]!; + } + return comparePrerelease(actual.prerelease, minimum.prerelease) >= 0; +} + +function compatibilityToken(input: ServiceTakeoverCompatibilityInput): string { + return createHash("sha256").update(JSON.stringify({ + subject: input.subject, + protocolVersion: input.state?.ownershipProtocolVersion ?? null, + managers: { + "service-registration": input.managers["service-registration"], + path: input.managers.path, + }, + })).digest("hex"); +} + +/** + * Decide whether permanent desktop ownership can be offered. + * + * Both managing surfaces are mandatory observations. `absent` is a trustworthy answer; + * `unknown` is not. An observed service registration additionally needs the protocol marker + * written by a CLI whose start/repair/update paths honor the ownership claim. This is what + * keeps the preserved registration from starting an older runtime at the next login. + */ +export function assessServiceTakeoverCompatibility( + input: ServiceTakeoverCompatibilityInput, +): ServiceTakeoverCompatibility { + const observed = Object.entries(input.managers) as Array<[ManagingCliRole, ManagingCliObservation]>; + const unknown = observed.find(([, manager]) => manager.status === "unknown"); + if (unknown) return { + kind: "blocked", + reason: "managing-cli-unknown", + detail: `${unknown[0]} compatibility could not be determined`, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + const present = observed.filter(([, manager]) => manager.status === "observed") as Array<[ + ManagingCliRole, + Extract, + ]>; + if (present.length === 0) return { + kind: "blocked", + reason: "managing-cli-unobserved", + detail: "no managing OpenCodex CLI installation was observed", + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + const unsupported = present.find(([, manager]) => !versionSupportsOwnership(manager.version)); + if (unsupported) return { + kind: "blocked", + reason: "managing-cli-unsupported", + detail: `${unsupported[0]} uses OpenCodex ${unsupported[1].version}; ${SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION} or later is required`, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + if (input.managers["service-registration"].status === "observed" + && input.state?.ownershipProtocolVersion !== SERVICE_OWNERSHIP_PROTOCOL_VERSION) { + return { + kind: "blocked", + reason: "service-protocol-unsupported", + detail: "the preserved service registration was not written by an ownership-aware CLI", + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + } + return { + kind: "supported", + protocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + token: compatibilityToken(input), + }; +} + +export function sameServiceTakeoverCompatibility( + left: ServiceTakeoverCompatibility, + right: ServiceTakeoverCompatibility, +): boolean { + return left.kind === "supported" && right.kind === "supported" && left.token === right.token; +} diff --git a/src/service/ownership-mutation-lease.d.mts b/src/service/ownership-mutation-lease.d.mts new file mode 100644 index 00000000000..574df756c6f --- /dev/null +++ b/src/service/ownership-mutation-lease.d.mts @@ -0,0 +1,23 @@ +export interface OwnershipMutationLeaseOptions { + readonly waitMs?: number; + readonly now?: () => number; + readonly sleep?: (ms: number) => void; + readonly processAlive?: (pid: number) => boolean; + readonly beforeRelease?: (lockPath: string) => void; + readonly joinToken?: string; +} + +export interface OwnershipMutationLease { readonly token: string; release(): void } + +export declare const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV: "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; + +export declare function acquireOwnershipMutationLease( + statePaths: readonly string[], + options?: OwnershipMutationLeaseOptions, +): OwnershipMutationLease; + +export declare function withOwnershipMutationLease( + statePaths: readonly string[], + run: () => T, + options?: OwnershipMutationLeaseOptions, +): T; diff --git a/src/service/ownership-mutation-lease.mjs b/src/service/ownership-mutation-lease.mjs new file mode 100644 index 00000000000..3bb9919d227 --- /dev/null +++ b/src/service/ownership-mutation-lease.mjs @@ -0,0 +1,201 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +const WAIT_MS = 2_000; +const POLL_MS = 20; +const STALE_MS = 30_000; +const PROCESS_INSTANCE = randomUUID(); +const held = new Map(); +const delegatedTokens = new Map(); +const sleeper = new Int32Array(new SharedArrayBuffer(4)); +export const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV = "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; + +function sleep(ms) { Atomics.wait(sleeper, 0, 0, ms); } +function processAlive(pid) { + try { process.kill(pid, 0); return true; } + catch (error) { return error?.code !== "ESRCH"; } +} + +function leasePath(statePaths) { + const authority = statePaths.at(-1); + if (!authority) throw new Error("cannot acquire ownership mutation lease without a service-state path"); + try { return `${realpathSync.native(authority)}.mutation.lock`; } + catch { + try { return join(realpathSync.native(dirname(authority)), `${basename(authority)}.mutation.lock`); } + catch { return `${authority}.mutation.lock`; } + } +} + +function ownerName(record) { + return `v1-${record.pid}-${record.processInstance}-${record.token}.json`; +} + +function parseOwnerName(name) { + const match = /^v1-([1-9][0-9]*)-[0-9a-f-]+-[0-9a-f-]+[.]json$/i.exec(name); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; +} + +function readOwner(path) { + try { + const lock = lstatSync(path); + if (!lock.isDirectory()) return null; + const entries = readdirSync(path); + if (entries.length !== 1) return null; + const ownerPath = join(path, entries[0]); + const owner = lstatSync(ownerPath); + if (!owner.isFile() || owner.size > 4096) return null; + const record = JSON.parse(readFileSync(ownerPath, "utf8")); + if (record?.version !== 1 || !Number.isSafeInteger(record.pid) || record.pid <= 0 + || typeof record.processInstance !== "string" || !record.processInstance + || typeof record.token !== "string" || !record.token + || !Number.isFinite(record.createdAt) || entries[0] !== ownerName(record)) return null; + const currentLock = lstatSync(path); + const currentOwner = lstatSync(ownerPath); + if (currentLock.dev !== lock.dev || currentLock.ino !== lock.ino + || currentOwner.dev !== owner.dev || currentOwner.ino !== owner.ino + || currentOwner.size !== owner.size) return null; + return { path, ownerPath, record, lockDev: lock.dev, lockIno: lock.ino, ownerDev: owner.dev, ownerIno: owner.ino, ownerSize: owner.size, mtimeMs: owner.mtimeMs }; + } catch { return null; } +} + +function sameOwner(left, right) { + return left.record.token === right.record.token + && left.record.pid === right.record.pid + && left.record.processInstance === right.record.processInstance + && left.lockDev === right.lockDev && left.lockIno === right.lockIno + && left.ownerDev === right.ownerDev && left.ownerIno === right.ownerIno + && left.ownerSize === right.ownerSize; +} + +function readIncompleteOwner(path) { + try { + const lock = lstatSync(path); + if (!lock.isDirectory()) return null; + const entries = readdirSync(path); + if (entries.length === 0) return { path, ownerPath: null, pid: null, mtimeMs: lock.mtimeMs }; + if (entries.length !== 1) return null; + const pid = parseOwnerName(entries[0]); + if (!pid) return null; + const ownerPath = join(path, entries[0]); + const owner = lstatSync(ownerPath); + return owner.isFile() ? { path, ownerPath, pid, mtimeMs: owner.mtimeMs } : null; + } catch { return null; } +} + +function reclaim(path, now, alive) { + const observed = readOwner(path); + const incomplete = observed ? null : readIncompleteOwner(path); + if (!observed && !incomplete) return false; + const createdAt = observed ? Math.max(observed.record.createdAt, observed.mtimeMs) : incomplete.mtimeMs; + const pid = observed?.record.pid ?? incomplete.pid; + if (now() - createdAt <= STALE_MS || (pid !== null && alive(pid))) return false; + if (observed) { + const current = readOwner(path); + if (!current || !sameOwner(observed, current)) return false; + } + try { + const ownerPath = observed?.ownerPath ?? incomplete.ownerPath; + if (ownerPath) unlinkSync(ownerPath); + rmdirSync(path); + return true; + } catch { return false; } +} + +export function acquireOwnershipMutationLease( + statePaths, + options = {}, +) { + const path = leasePath(statePaths); + const nested = held.get(path); + if (nested) { + nested.depth += 1; + return { token: nested.snapshot.record.token, release: () => release(path, options) }; + } + const now = options.now ?? Date.now; + const alive = options.processAlive ?? processAlive; + const explicitJoinToken = options.joinToken; + const envJoinToken = process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + const joinToken = explicitJoinToken ?? envJoinToken ?? delegatedTokens.get(path); + if (joinToken) { + const owner = readOwner(path); + if (owner?.record.token === joinToken && alive(owner.record.pid)) { + delegatedTokens.set(path, joinToken); + if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + held.set(path, { depth: 1, snapshot: owner, delegated: true }); + return { token: joinToken, release: () => release(path, options) }; + } + delegatedTokens.delete(path); + if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + if (explicitJoinToken) throw new Error("ownership mutation lease delegation is invalid or no longer live"); + } + const wait = options.waitMs ?? WAIT_MS; + const deadline = now() + wait; + if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + for (;;) { + const record = { version: 1, pid: process.pid, processInstance: PROCESS_INSTANCE, token: randomUUID(), createdAt: now() }; + const ownerPath = join(path, ownerName(record)); + let madeDirectory = false; + let descriptor = null; + try { + mkdirSync(path, { mode: 0o700 }); + madeDirectory = true; + descriptor = openSync(ownerPath, "wx", 0o600); + writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + const snapshot = readOwner(path); + if (!snapshot || snapshot.record.token !== record.token) throw new Error("ownership mutation lease could not be verified"); + held.set(path, { depth: 1, snapshot, delegated: false }); + return { token: record.token, release: () => release(path, options) }; + } catch (error) { + if (descriptor !== null) { try { closeSync(descriptor); } catch { /* stale recovery owns uncertain cleanup */ } } + if (madeDirectory) { + try { unlinkSync(ownerPath); } catch { /* partial owner is recovered after dead-PID proof */ } + try { rmdirSync(path); } catch { /* owner entry or successor keeps the directory live */ } + } + if (error?.code !== "EEXIST") throw error; + if (reclaim(path, now, alive)) continue; + if (now() >= deadline) throw new Error(`another process owns the runtime mutation lease at ${path}`); + (options.sleep ?? sleep)(POLL_MS); + } + } +} + +function release(path, options) { + const currentHeld = held.get(path); + if (!currentHeld) return; + currentHeld.depth -= 1; + if (currentHeld.depth > 0) return; + held.delete(path); + if (currentHeld.delegated) return; + options.beforeRelease?.(path); + try { + const current = readOwner(path); + if (!current || !sameOwner(currentHeld.snapshot, current)) return; + unlinkSync(currentHeld.snapshot.ownerPath); + rmdirSync(path); + } catch { /* token-specific stale recovery handles an uncertain release */ } +} + +export function withOwnershipMutationLease(statePaths, run, options = {}) { + const lease = acquireOwnershipMutationLease(statePaths, options); + try { return run(); } + finally { lease.release(); } +} diff --git a/src/service/repair.ts b/src/service/repair.ts index ad58e93118a..34937498238 100644 --- a/src/service/repair.ts +++ b/src/service/repair.ts @@ -92,7 +92,7 @@ export function unknownServiceOwnerRefusal(reason: string, action = "repair"): s return `Background service ${action} stopped: ${reason}, so the runtime's recorded owner could ` + "not be determined.\n" + "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n" - + "Run 'ocx service install' to re-register the service and take the runtime back."; + + "Repair the service-state file or its permissions, then run 'ocx service install' to take the runtime back."; } async function assertSchedulerSnapshotBeforeStart( diff --git a/src/service/state-lock.ts b/src/service/state-lock.ts new file mode 100644 index 00000000000..6bc2fabfb92 --- /dev/null +++ b/src/service/state-lock.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync, + type Stats, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +const SERVICE_STATE_LOCK_WAIT_MS = 2_000; +const SERVICE_STATE_LOCK_POLL_MS = 20; +const SERVICE_STATE_LOCK_STALE_MS = 30_000; +const PROCESS_INSTANCE = randomUUID(); + +interface ServiceStateLockRecord { + readonly version: 1; + readonly pid: number; + readonly processInstance: string; + readonly token: string; + readonly createdAt: number; +} + +interface ServiceStateLockSnapshot { + readonly record: ServiceStateLockRecord; + readonly ownerPath: string; + readonly lockIdentity: Pick; + readonly ownerIdentity: Pick; + readonly mtimeMs: number; +} + +export interface ServiceStateLockHooks { + readonly now?: () => number; + readonly sleep?: (ms: number) => void; + readonly processAlive?: (pid: number) => boolean; + readonly beforeStaleDelete?: (lockPath: string) => void; + readonly beforeRelease?: (lockPath: string) => void; +} + +interface HeldServiceStateLock { + depth: number; + readonly snapshot: ServiceStateLockSnapshot; +} + +const heldLocks = new Map(); + +function lockPathForStatePath(statePath: string): string { + try { return `${realpathSync.native(statePath)}.lock`; } + catch { + try { return join(realpathSync.native(dirname(statePath)), `${basename(statePath)}.lock`); } + catch { return `${statePath}.lock`; } + } +} + +function lockOwnerProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but this account cannot signal it. Unknown failures + // also fail closed: only ESRCH proves the holder is gone. + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +function sameIdentity( + left: Pick, + right: Pick, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function ownerFileName(record: ServiceStateLockRecord): string { + return `v1-${record.pid}-${record.processInstance}-${record.token}.json`; +} + +function parseOwnerFileName(name: string): { pid: number; pathToken: string } | null { + const match = /^v1-([1-9][0-9]*)-([0-9a-f-]+)-([0-9a-f-]+)[.]json$/i.exec(name); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? { pid, pathToken: `${match[2]}-${match[3]}` } : null; +} + +function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { + let lockIdentity: Stats; + let entries: string[]; + try { + lockIdentity = lstatSync(lockPath); + if (!lockIdentity.isDirectory()) return null; + entries = readdirSync(lockPath); + } catch { + return null; + } + if (entries.length !== 1 || !parseOwnerFileName(entries[0]!)) return null; + const ownerPath = join(lockPath, entries[0]!); + try { + const ownerIdentity = lstatSync(ownerPath); + if (!ownerIdentity.isFile() || ownerIdentity.size > 4096) return null; + const value = JSON.parse(readFileSync(ownerPath, "utf8")) as Partial; + if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0 + || typeof value.processInstance !== "string" || value.processInstance.length === 0 + || typeof value.token !== "string" || value.token.length === 0 + || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; + const record = value as ServiceStateLockRecord; + if (entries[0] !== ownerFileName(record)) return null; + const currentLock = lstatSync(lockPath); + const currentOwner = lstatSync(ownerPath); + if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock) + || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner) + || currentOwner.size !== ownerIdentity.size) return null; + return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: ownerIdentity.mtimeMs }; + } catch { + return null; + } +} + +function sameLock(left: ServiceStateLockSnapshot, right: ServiceStateLockSnapshot): boolean { + return left.record.token === right.record.token + && left.record.pid === right.record.pid + && left.record.processInstance === right.record.processInstance + && sameIdentity(left.lockIdentity, right.lockIdentity) + && sameIdentity(left.ownerIdentity, right.ownerIdentity) + && left.ownerIdentity.size === right.ownerIdentity.size; +} + +function parsedIncompleteOwner(lockPath: string): { ownerPath: string | null; pid: number | null; mtimeMs: number } | null { + try { + const lock = lstatSync(lockPath); + if (!lock.isDirectory()) return null; + const entries = readdirSync(lockPath); + if (entries.length === 0) return { ownerPath: null, pid: null, mtimeMs: lock.mtimeMs }; + if (entries.length !== 1) return null; + const parsed = parseOwnerFileName(entries[0]!); + if (!parsed) return null; + const ownerPath = join(lockPath, entries[0]!); + const owner = lstatSync(ownerPath); + return owner.isFile() ? { ownerPath, pid: parsed.pid, mtimeMs: owner.mtimeMs } : null; + } catch { + return null; + } +} + +function reclaimStaleLock(lockPath: string, hooks: ServiceStateLockHooks): boolean { + const now = hooks.now ?? Date.now; + const processAlive = hooks.processAlive ?? lockOwnerProcessAlive; + const snapshot = readLockSnapshot(lockPath); + const incomplete = snapshot ? null : parsedIncompleteOwner(lockPath); + if (!snapshot && !incomplete) return false; + const ownerPath = snapshot?.ownerPath ?? incomplete!.ownerPath; + const ownerPid = snapshot?.record.pid ?? incomplete!.pid; + const createdAt = snapshot ? Math.max(snapshot.record.createdAt, snapshot.mtimeMs) : incomplete!.mtimeMs; + if (now() - createdAt <= SERVICE_STATE_LOCK_STALE_MS || (ownerPid !== null && processAlive(ownerPid))) { + return false; + } + if (snapshot) { + const current = readLockSnapshot(lockPath); + if (!current || !sameLock(snapshot, current)) return false; + } + hooks.beforeStaleDelete?.(lockPath); + try { + // The owner filename contains the holder's PID, process-instance nonce and token. A + // successor has a different name, so this unlink cannot delete the successor's owner. + if (ownerPath) unlinkSync(ownerPath); + rmdirSync(lockPath); + return true; + } catch { + return false; + } +} + +function acquireOne(lockPath: string, hooks: ServiceStateLockHooks, waitMs: number): ServiceStateLockSnapshot { + const held = heldLocks.get(lockPath); + if (held) { held.depth += 1; return held.snapshot; } + const now = hooks.now ?? Date.now; + const sleep = hooks.sleep ?? (ms => Bun.sleepSync(ms)); + const deadline = now() + waitMs; + if (!existsSync(dirname(lockPath))) mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + for (;;) { + const record: ServiceStateLockRecord = { + version: 1, + pid: process.pid, + processInstance: PROCESS_INSTANCE, + token: randomUUID(), + createdAt: now(), + }; + const ownerPath = join(lockPath, ownerFileName(record)); + let createdDirectory = false; + let descriptor: number | null = null; + try { + mkdirSync(lockPath, { mode: 0o700 }); + createdDirectory = true; + descriptor = openSync(ownerPath, "wx", 0o600); + writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + const snapshot = readLockSnapshot(lockPath); + if (!snapshot || snapshot.record.token !== record.token) throw new Error("service state lock ownership could not be verified"); + heldLocks.set(lockPath, { depth: 1, snapshot }); + return snapshot; + } catch (error) { + if (descriptor !== null) { try { closeSync(descriptor); } catch { /* best-effort */ } } + if (createdDirectory) { + try { unlinkSync(ownerPath); } catch { /* incomplete owner may remain for dead-PID recovery */ } + try { rmdirSync(lockPath); } catch { /* another entry or uncertain owner remains */ } + } + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + if (reclaimStaleLock(lockPath, hooks)) continue; + if (now() >= deadline) { + throw new Error(`another process owns the service state lock at ${lockPath}; nothing was written`); + } + sleep(SERVICE_STATE_LOCK_POLL_MS); + } + } +} + +function releaseOne(lockPath: string, hooks: ServiceStateLockHooks): void { + const held = heldLocks.get(lockPath); + if (!held) return; + held.depth -= 1; + if (held.depth > 0) return; + heldLocks.delete(lockPath); + hooks.beforeRelease?.(lockPath); + try { + const current = readLockSnapshot(lockPath); + if (!current || !sameLock(held.snapshot, current)) return; + unlinkSync(held.snapshot.ownerPath); + rmdirSync(lockPath); + } catch { /* a verified future holder or stale recovery owns cleanup */ } +} + +export function assertServiceStateLocksOwned(statePaths: readonly string[]): void { + for (const path of statePaths) { + const lockPath = lockPathForStatePath(path); + const held = heldLocks.get(lockPath); + const current = readLockSnapshot(lockPath); + if (!held || !current || !sameLock(held.snapshot, current)) { + throw new Error(`service state lock ownership changed before commit: ${lockPath}`); + } + } +} + +export function withServiceStateLocks( + statePaths: readonly string[], + run: () => T, + options: { readonly waitMs?: number; readonly hooks?: ServiceStateLockHooks } = {}, +): T { + const hooks = options.hooks ?? {}; + const lockPaths = [...new Set(statePaths.map(lockPathForStatePath))].sort(); + const acquired: string[] = []; + try { + for (const lockPath of lockPaths) { + acquireOne(lockPath, hooks, options.waitMs ?? SERVICE_STATE_LOCK_WAIT_MS); + acquired.push(lockPath); + } + return run(); + } finally { + for (const lockPath of acquired.reverse()) releaseOne(lockPath, hooks); + } +} diff --git a/src/service/state-record.d.mts b/src/service/state-record.d.mts new file mode 100644 index 00000000000..6fa0eb77efd --- /dev/null +++ b/src/service/state-record.d.mts @@ -0,0 +1,36 @@ +export interface ServiceOwnershipRecord { + readonly owner: "cli" | "desktop"; + readonly installId: string; + readonly consentGeneration: number; + readonly [key: string]: unknown; +} + +export declare const SERVICE_OWNERSHIP_PROTOCOL_VERSION: 1; +export declare const SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION: "2.61.0"; + +export interface ServiceInstallStateRecord { + readonly version: 1 | 2; + readonly codexHome: string; + readonly opencodexHome: string; + readonly revision?: number; + readonly ownership?: ServiceOwnershipRecord; + readonly [key: string]: unknown; +} + +export type ServiceStateRecordEvidence = + | { readonly path: string; readonly kind: "absent" } + | { readonly path: string; readonly kind: "unreadable"; readonly reason: string } + | { readonly path: string; readonly kind: "invalid" } + | { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallStateRecord }; + +export type AuthoritativeServiceStateRecord = + | { readonly kind: "none"; readonly revision: 0; readonly needsRepair: false } + | { readonly kind: "state"; readonly state: ServiceInstallStateRecord; readonly revision: number; readonly needsRepair: boolean } + | { readonly kind: "unknown"; readonly reason: string }; + +export declare function parseServiceOwnershipRecord(value: unknown): ServiceOwnershipRecord | null; +export declare function parseServiceInstallStateRecord(value: unknown): ServiceInstallStateRecord | null; +export declare function serviceStatePathsForHomes(opencodexHome: string, defaultOpenCodexHome: string, platform?: NodeJS.Platform): string[]; +export declare function inspectServiceStateRecords(paths: readonly string[], read?: (path: string) => string): readonly ServiceStateRecordEvidence[]; +export declare function serviceStateFingerprint(value: unknown): string; +export declare function selectAuthoritativeServiceState(evidence: readonly ServiceStateRecordEvidence[]): AuthoritativeServiceStateRecord; diff --git a/src/service/state-record.mjs b/src/service/state-record.mjs new file mode 100644 index 00000000000..dc0270cb469 --- /dev/null +++ b/src/service/state-record.mjs @@ -0,0 +1,138 @@ +import { readFileSync, realpathSync } from "node:fs"; +import { posix, win32 } from "node:path"; + +export const SERVICE_OWNERSHIP_PROTOCOL_VERSION = 1; +export const SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION = "2.61.0"; + +const isObject = value => Boolean(value) && typeof value === "object" && !Array.isArray(value); +const isNonNegativeSafeInteger = value => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const nonEmptyString = value => typeof value === "string" && value.length > 0; + +/** Parse the ownership payload shared by the Bun service code and the Node launcher. */ +export function parseServiceOwnershipRecord(value) { + if (!isObject(value)) return null; + if (value.owner !== "cli" && value.owner !== "desktop") return null; + if (!nonEmptyString(value.installId) || !isNonNegativeSafeInteger(value.consentGeneration)) return null; + return value; +} + +/** Parse the COMPLETE install record; ownership alone is not enough to trust the file. */ +export function parseServiceInstallStateRecord(value) { + if (!isObject(value) || (value.version !== 1 && value.version !== 2)) return null; + if (!nonEmptyString(value.codexHome) || !nonEmptyString(value.opencodexHome)) return null; + for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"]) { + if (value[key] !== undefined && !nonEmptyString(value[key])) return null; + } + if (value.cliPath !== undefined && value.cliPath !== null && !nonEmptyString(value.cliPath)) return null; + if (value.revision !== undefined && !isNonNegativeSafeInteger(value.revision)) return null; + if (value.consentGenerationCeiling !== undefined && !isNonNegativeSafeInteger(value.consentGenerationCeiling)) return null; + if (value.ownershipProtocolVersion !== undefined && !isNonNegativeSafeInteger(value.ownershipProtocolVersion)) return null; + if (value.ownership !== undefined && parseServiceOwnershipRecord(value.ownership) === null) return null; + if (value.version === 1) { + if (value.backend !== undefined) return null; + } else if (value.backend !== "scheduler" && value.backend !== "native") return null; + return value; +} + +export function serviceStatePathsForHomes(opencodexHome, defaultOpenCodexHome, platform = process.platform) { + const tools = platform === "win32" ? win32 : posix; + const primary = tools.join(opencodexHome, "service-state.json"); + const legacy = tools.join(defaultOpenCodexHome, "service-state.json"); + const key = path => { + let canonical; + try { canonical = realpathSync.native(path); } + catch { + try { canonical = tools.join(realpathSync.native(tools.dirname(path)), tools.basename(path)); } + catch { canonical = tools.resolve(path); } + } + return platform === "win32" ? canonical.toLowerCase() : canonical; + }; + return key(primary) === key(legacy) ? [primary] : [primary, legacy]; +} + +function errorCode(error) { + return error && typeof error === "object" && "code" in error ? String(error.code ?? "") : ""; +} + +/** Read every supplied state path without collapsing absent, invalid and unreadable. */ +export function inspectServiceStateRecords(paths, read = path => readFileSync(path, "utf8")) { + return paths.map(path => { + let raw; + try { + raw = read(path); + } catch (error) { + const code = errorCode(error); + return code === "ENOENT" + ? { path, kind: "absent" } + : { path, kind: "unreadable", reason: code || String(error) }; + } + try { + const state = parseServiceInstallStateRecord(JSON.parse(raw)); + return state ? { path, kind: "valid", state } : { path, kind: "invalid" }; + } catch { + return { path, kind: "invalid" }; + } + }); +} + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (!isObject(value)) return JSON.stringify(value); + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; +} + +export function serviceStateFingerprint(value) { + return canonical(value); +} + +/** + * Select one authoritative generation from mirrored state. + * + * The final path is authoritative. A lower-revision mirror is repairable; a same-or-newer + * disagreement is unordered evidence and fails closed. Before the authority exists, one valid + * active-home record is imported exactly once as migration input. + */ +export function selectAuthoritativeServiceState(evidence) { + const authority = evidence.at(-1); + if (!authority) return { kind: "none", revision: 0, needsRepair: false }; + if (authority.kind === "unreadable") { + return { kind: "unknown", reason: `the authoritative service state could not be read (${authority.reason})` }; + } + if (authority.kind === "invalid") { + return { kind: "unknown", reason: "the authoritative service install record is present but not valid" }; + } + if (authority.kind === "valid") { + const fingerprint = canonical(authority.state); + const authorityRevision = authority.state.revision ?? 0; + const unorderedConflict = evidence.slice(0, -1).find(entry => entry.kind === "valid" + && (entry.state.revision ?? 0) >= authorityRevision + && canonical(entry.state) !== fingerprint); + if (unorderedConflict) { + return { kind: "unknown", reason: `a service state mirror conflicts with authority revision ${authorityRevision}` }; + } + return { + kind: "state", + state: authority.state, + revision: authorityRevision, + needsRepair: evidence.slice(0, -1).some(entry => entry.kind !== "valid" || canonical(entry.state) !== fingerprint), + }; + } + + // The authority has never been established. A single valid active-home mirror is the + // migration source; after the first write it can no longer vote against the authority. + const migration = evidence.slice(0, -1); + const unreadable = migration.find(entry => entry.kind === "unreadable"); + if (unreadable) return { kind: "unknown", reason: `a legacy service state path could not be read (${unreadable.reason})` }; + if (migration.some(entry => entry.kind === "invalid")) { + return { kind: "unknown", reason: "a legacy service install record is present but not valid" }; + } + const valid = migration.filter(entry => entry.kind === "valid"); + if (valid.length === 0) return { kind: "none", revision: 0, needsRepair: false }; + const revision = Math.max(...valid.map(entry => entry.state.revision ?? 0)); + const newest = valid.filter(entry => (entry.state.revision ?? 0) === revision); + const fingerprint = canonical(newest[0].state); + if (newest.some(entry => canonical(entry.state) !== fingerprint)) { + return { kind: "unknown", reason: `legacy service state mirrors disagree at revision ${revision}` }; + } + return { kind: "state", state: newest[0].state, revision, needsRepair: true }; +} diff --git a/src/service/state.ts b/src/service/state.ts index f4b75753636..dbd2520b07b 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -1,23 +1,33 @@ -import { accessSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { randomUUID } from "node:crypto"; +import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; +import { atomicWriteFileStreamed } from "../config/atomic-write"; import { resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home"; import { resolveCodexSqliteHome } from "../codex/paths"; import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw"; -import { hardenSecretPath } from "../lib/windows-secret-acl"; -import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isProtectedHomeUnderTest, isTestHomeGuardArmed } from "../lib/test-home-guard"; import { isStandaloneBinary } from "../lib/standalone"; import { inspectInstallStateBytes, parseInstallStateRecord, parseOwnershipClaim, - resolveOwnershipFromEvidence, + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, serviceStateFilesFor, } from "./install-state-contract.mjs"; +import type { ServiceStateRecordEvidence } from "./state-record.mjs"; +import { assertServiceStateLocksOwned, withServiceStateLocks, type ServiceStateLockHooks } from "./state-lock"; +import { withOwnershipMutationLease, type OwnershipMutationLeaseOptions } from "./ownership-mutation-lease.mjs"; +import { + assessServiceTakeoverCompatibility, + sameServiceTakeoverCompatibility, + type ManagingCliObservation, + type ServiceTakeoverCompatibility, +} from "./ownership-compatibility"; /** * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in @@ -28,6 +38,7 @@ export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED"; export const LABEL = "com.opencodex.proxy"; export const TASK = "opencodex-proxy"; +export { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, SERVICE_OWNERSHIP_PROTOCOL_VERSION }; // This module lives one level below the original src/service.ts, so path-relative // lookups anchored at that file's directory go through this constant instead. @@ -143,8 +154,6 @@ function defaultOpenCodexHome(): string { } export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] { - // Shared with the Node launcher, which has to consult the SAME list: reading only the - // anchor is how it missed a claim recorded on the legacy default-home path. return serviceStateFilesFor(opencodexHome, defaultOpenCodexHome()); } @@ -264,6 +273,8 @@ export interface ServiceInstallState { * one as its own prior consent. */ consentGenerationCeiling?: number; + /** Written only by CLIs whose start/repair/update paths honor a desktop claim. */ + ownershipProtocolVersion?: number; } /** @@ -341,6 +352,7 @@ function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string codexSqliteHome: resolveCodexSqliteHome({ codexHome }), bunPath: bun, cliPath: cli, + ownershipProtocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION, ...(launcherPath ? { launcherPath } : {}), backend, ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}), @@ -357,40 +369,22 @@ function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string * runtime back to the npm launcher without saying anything. Preserving it here is what makes * the consent durable. */ -export function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void { +export function writeServiceInstallState( + backend: ServiceBackend = "scheduler", + launcherPath?: string | null, + deps: ServiceStateSwapDeps = {}, +): void { swapServiceInstallState(current => ({ ...installProvenanceRecord(backend, launcherPath), - // Resolved INSIDE the swap, which runs while the anchor lock is held, and across every - // state path so a claim living only on the legacy mirror is carried onto the anchor. - // - // Resolving before the lock was a lost-update window of its own: a takeover recorded - // between the resolution and the swap's base read lands in `current`, passes the revision - // check untouched, and is then overwritten by the older claim this function captured. - // The compare-and-swap cannot see that, because the stale value never came from the base. - // - // This does NOT refuse on an unknown resolution. It runs at the END of a successful - // install or repair, where a throw would report a service that is registered and running - // as a failure. The fail-closed decision belongs in front of the mutation, where repair - // and the updaters make it; here the job is to preserve as much as can be read. - ...preservedConsent(current, resolveServiceOwnership()), - })); + ...preservedConsent(current), + }), deps); } /** The ownership half of a record: the claim itself plus the generation high-water mark. */ function preservedConsent( current: ServiceInstallState | null, - resolution: ServiceOwnershipResolution, ): Pick { - // Both inputs are read under the lock, and they can still disagree: `current` is the anchor - // alone, the resolution spans every path. Never let the older grant win, and on an equal - // generation keep `current` — the anchor is the record every reader resolves first, so - // preferring it is the fail-safe tie. - const resolved = resolution.kind === "owned" ? resolution.ownership : undefined; - const ownership = resolved === undefined - ? current?.ownership - : current?.ownership && current.ownership.consentGeneration >= resolved.consentGeneration - ? current.ownership - : resolved; + const ownership = current?.ownership; const ceiling = Math.max(current?.consentGenerationCeiling ?? 0, ownership?.consentGeneration ?? 0); return { ...(ownership ? { ownership } : {}), @@ -399,23 +393,16 @@ function preservedConsent( } export function readServiceInstallState(): ServiceInstallState | null { - for (const path of serviceStatePaths()) { - try { - const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8"))); - if (parsed) return parsed; - } catch { - /* try the next known state path */ - } - } - return null; + const resolved = resolveServiceState(); + return resolved.kind === "state" ? resolved.state : null; } -/** Raised when a state write kept losing its compare-and-swap; NOTHING was written. */ +/** Raised when a non-cooperating writer prevents a stable authoritative commit. */ export class ServiceStateConflictError extends Error { constructor(readonly path: string, readonly attempts: number) { super( `service install state at ${path} was rewritten by another process during all ${attempts} ` - + "compare-and-swap attempts; nothing was written. Re-run the command.", + + "compare-and-swap attempts; a stable commit could not be verified. Re-run the command.", ); this.name = "ServiceStateConflictError"; } @@ -434,177 +421,43 @@ export interface ServiceStateSwapDeps { beforeCommit?: (attempt: number) => void; /** How long to wait for another process to release the anchor lock. */ lockWaitMs?: number; + /** Deterministic lock seams for failure-order tests. */ + lockHooks?: ServiceStateLockHooks; + /** Atomic publisher seam. The callback must run immediately before its commit point. */ + commitStateFile?: (path: string, serialized: string, validate: () => void) => void; + /** A mirror failure occurs after the authority committed and is therefore diagnostic. */ + onMirrorError?: (path: string, error: unknown) => void; + /** Allows consented mutations to preserve a machine-readable unknown-subject error. */ + unknownStateError?: (reason: string) => Error; + /** Shared with update/install/start so replacement and ownership mutation cannot overlap. */ + mutationLease?: OwnershipMutationLeaseOptions; } -const SERVICE_STATE_SWAP_ATTEMPTS = 5; -const SERVICE_STATE_LOCK_WAIT_MS = 2_000; -const SERVICE_STATE_LOCK_POLL_MS = 20; -/** - * How old a lock must be before it is treated as abandoned. - * - * It has to exceed the longest legitimate critical section, not the typical one. On Windows - * each committed path runs `hardenSecretPath` synchronously, whose own documentation records - * a worst case around ninety seconds for sequential calls under load; a thirty-second - * threshold would let a second writer evict a holder that is simply still working, and both - * would then compute the same base revision and write over each other. - */ -const SERVICE_STATE_LOCK_STALE_MS = 300_000; -const SERVICE_STATE_REPLACE_ATTEMPTS = 5; -const SERVICE_STATE_REPLACE_RETRY_MS = 40; -/** Lock paths this process holds, with the token written into each and a re-entrancy depth. */ -const heldStateLocks = new Map(); - -/** The token inside a lock file, or null when it cannot be read. */ -function readLockToken(lockPath: string): string | null { - try { return readFileSync(lockPath, "utf8").trim() || null; } catch { return null; } -} - -function isFileExistsError(error: unknown): boolean { - return Boolean(error) && typeof error === "object" && "code" in (error as object) - && (error as { code?: unknown }).code === "EEXIST"; +export interface ServiceStateMutationContext { + readonly revision: number; } -/** - * Hold an exclusive lock over the anchor record for one whole read-modify-write. - * - * The revision check alone cannot make the swap atomic: two processes can both pass it, - * both commit, and both verify their own bytes, after which the second silently drops the - * first's mutation and reports success. `O_EXCL` creation is the cheap cross-process - * exclusion that closes it for every writer that comes through here. - * - * The revision check stays anyway, because this lock binds only cooperating writers — an - * older `ocx` on the same machine does not take it. - * - * Re-entrant per process. A swap nested inside another one is a caller ordering its own - * writes, not a race, and blocking it would be a self-deadlock. - */ -function withServiceStateLock(anchor: string, run: () => T, waitMs = SERVICE_STATE_LOCK_WAIT_MS): T { - const lockPath = `${anchor}.lock`; - const held = heldStateLocks.get(lockPath); - if (held !== undefined) { - held.depth += 1; - try { return run(); } finally { releaseHeldLock(lockPath); } - } - const dir = dirname(lockPath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - const deadline = Date.now() + waitMs; - const token = randomUUID(); - let fd: number; - for (;;) { - try { - fd = openSync(lockPath, "wx", 0o600); - break; - } catch (error) { - // A lock we could not create for any reason OTHER than "it is held" is a filesystem - // failure, and writing the record anyway is the unprotected path this exists to close. - if (!isFileExistsError(error)) throw error; - if (Date.now() < deadline) { Bun.sleepSync(SERVICE_STATE_LOCK_POLL_MS); continue; } - // Break a lock whose holder is gone. Age comes from the lock file itself, so a holder - // that is merely slow keeps refusing us rather than being evicted mid-write. - // - // The token is re-read and compared before the unlink: without it, a holder that - // released and a NEW holder that took the lock in the same instant would be evicted as - // if it were the abandoned one, and two writers would proceed from one base revision. - const abandoned = readLockToken(lockPath); - let ageMs: number | null = null; - try { ageMs = Date.now() - statSync(lockPath).mtimeMs; } catch { ageMs = null; } - if (ageMs !== null && ageMs > SERVICE_STATE_LOCK_STALE_MS) { - if (readLockToken(lockPath) === abandoned) { - try { unlinkSync(lockPath); } catch { /* another process broke it first */ } - } - continue; - } - throw new Error( - `another process is writing the service install state at ${anchor} and did not release ` - + `it within ${waitMs}ms; nothing was written. Re-run the command.`, - ); - } - } - // Identify the holder inside the file so neither eviction nor release can remove a lock - // some other process has since taken. - try { writeFileSync(fd, `${token}\n`, { encoding: "utf8" }); } catch { /* best-effort */ } - heldStateLocks.set(lockPath, { depth: 1, token }); - try { - return run(); - } finally { - try { closeSync(fd); } catch { /* best-effort */ } - releaseHeldLock(lockPath); - } -} +const SERVICE_STATE_SWAP_ATTEMPTS = 5; -function releaseHeldLock(lockPath: string): void { - const held = heldStateLocks.get(lockPath); - if (held === undefined) return; - held.depth -= 1; - if (held.depth > 0) return; - heldStateLocks.delete(lockPath); - // Remove OUR lock instance only. If the file on disk carries a different token, this - // holder was evicted as stale and someone else owns the pathname now; unlinking it would - // hand a third writer the lock while the second is still inside its critical section. - if (readLockToken(lockPath) !== held.token) return; - try { unlinkSync(lockPath); } catch { /* best-effort */ } -} - -/** One state path's record, or null when it is absent or unparseable. Throws if unreadable. */ -function readServiceInstallStateAt(path: string): ServiceInstallState | null { - const evidence = inspectServiceStateEvidence([path])[0]!; - // Unreadable is not absent. Treating EACCES as "no record" would compute a swap from an - // empty base and erase an ownership claim we were merely not allowed to look at. - if (evidence.kind === "unreadable") { - throw new Error( - `service install state at ${path} could not be read (${evidence.reason}), so its recorded ` - + "owner cannot be preserved; nothing was written. Fix the file's permissions and retry.", - ); +function authoritativeState( + paths: readonly string[], + unknownStateError?: (reason: string) => Error, +): { current: ServiceInstallState | null; revision: number; fingerprint: string } { + const selected = selectAuthoritativeServiceState( + inspectServiceStateEvidence(paths) as readonly ServiceStateRecordEvidence[], + ); + if (selected.kind === "unknown") { + throw unknownStateError?.(selected.reason) ?? new Error(`${selected.reason}; nothing was written`); } - // Invalid IS overwritten: there is no claim in an unparseable record to preserve. - return evidence.kind === "valid" ? evidence.state : null; + if (selected.kind === "none") return { current: null, revision: 0, fingerprint: "none" }; + const current = selected.state as ServiceInstallState; + return { current, revision: selected.revision, fingerprint: serviceStateFingerprint(current) }; } -/** - * Publish one state file, replacing it as a unit. - * - * An in-place write truncates first, so a kill, a power loss or a failed write between the - * truncate and the last byte leaves the anchor empty or half-serialized. That used to read - * back as "no install state"; since the reader became fail-closed it reads as `unknown`, - * which blocks `service start`, repair, restart and every update until the operator runs a - * takeover install. Writing a sibling temporary file and renaming it means the previous valid - * record survives an interrupted commit. - * - * The temporary file is hardened BEFORE the rename, not after: between rename and chmod the - * record would otherwise be readable at the default mode. - * - * Windows can refuse the replace while a scanner or another reader holds the destination - * open. That is transient, so it is retried briefly and then falls back to the in-place - * write — a narrow torn-write window is a better failure than an install that cannot record - * what it just registered. - */ -function commitServiceStateFile(path: string, serialized: string): void { - const dir = dirname(path); - recordOwnedConfigPath(getConfigDir(), path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - const staged = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`; - try { - writeFileSync(staged, serialized, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(staged, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(staged, { required: true }); - for (let attempt = 0; ; attempt += 1) { - try { - renameSync(staged, path); - return; - } catch { - if (attempt >= SERVICE_STATE_REPLACE_ATTEMPTS - 1) { - writeFileSync(path, serialized, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: true }); - return; - } - Bun.sleepSync(SERVICE_STATE_REPLACE_RETRY_MS); - } - } - } finally { - // A rename that succeeded consumed the staged path; anything left is ours to clean up. - if (existsSync(staged)) { try { unlinkSync(staged); } catch { /* best-effort */ } } - } +function commitServiceStateFile(path: string, serialized: string, validate: () => void): void { + atomicWriteFileStreamed(path, descriptor => { + writeFileSync(descriptor, serialized, { encoding: "utf8" }); + }, { validateBeforeRename: validate }); } /** @@ -621,41 +474,79 @@ function commitServiceStateFile(path: string, serialized: string): void { * because two writers racing from one base both compute the same next revision — identical * bytes mean nothing was lost, and differing bytes mean something was. * - * WHAT THE LOCK IS. {@link withServiceStateLock} holds the anchor exclusively for the whole - * read-modify-write, because the revision check alone is not atomic: two processes can both - * pass it, both commit and both verify their own bytes, after which the second silently - * drops the first's mutation and reports success. The revision check remains the guard - * against a writer that does not take the lock, such as an older `ocx` on the same machine. + * The final path is the authority. With a custom home that is the legacy default-home path — + * the only path every writer can derive — and the active-home path is a compatibility mirror. + * The authority's atomic rename is the commit point. A mirror failure is reported but cannot + * roll back or reclassify the already committed mutation; the next writer repairs the mirror. */ export function swapServiceInstallState( - mutate: (current: ServiceInstallState | null) => ServiceInstallState | null, + mutate: (current: ServiceInstallState | null, context: ServiceStateMutationContext) => ServiceInstallState | null, deps: ServiceStateSwapDeps = {}, ): ServiceInstallState | null { const paths = deps.paths ?? serviceStateWritePaths(); - // The anchor is the first path, which is the state path for THIS OpenCodex home; - // `readServiceInstallState` reads the same list in the same order, so the record the - // swap compares against is the record every reader resolves. The remaining paths are - // legacy mirrors and receive a copy of whatever the anchor commits. - const anchor = paths[0]; - if (anchor === undefined) throw new Error("refusing to swap service install state with no state path"); + const authority = paths.at(-1); + if (authority === undefined) throw new Error("refusing to swap service install state with no state path"); + const mirrors = paths.filter(path => path !== authority); const attempts = deps.attempts ?? SERVICE_STATE_SWAP_ATTEMPTS; - return withServiceStateLock(anchor, () => { + const publish = deps.commitStateFile ?? commitServiceStateFile; + return withOwnershipMutationLease(paths, () => withServiceStateLocks(paths, () => { for (let attempt = 0; attempt < attempts; attempt += 1) { - const base = readServiceInstallStateAt(anchor); - const baseRevision = base?.revision ?? 0; - const candidate = mutate(base); - if (candidate === null) return base; - const next: ServiceInstallState = { ...candidate, revision: baseRevision + 1 }; + const base = authoritativeState(paths, deps.unknownStateError); + const candidate = mutate(base.current, { revision: base.revision }); + if (candidate === null) return base.current; + if (base.revision >= Number.MAX_SAFE_INTEGER) { + throw new Error("service state revision is exhausted; refusing to publish an unversioned mutation"); + } + const next: ServiceInstallState = { ...candidate, revision: base.revision + 1 }; const serialized = JSON.stringify(next, null, 2) + "\n"; deps.beforeCommit?.(attempt); - if ((readServiceInstallStateAt(anchor)?.revision ?? 0) !== baseRevision) continue; - for (const path of paths) commitServiceStateFile(path, serialized); - let committed: string | null = null; - try { committed = readFileSync(anchor, "utf8"); } catch { /* the comparison below decides */ } - if (committed === serialized) return next; + assertServiceStateLocksOwned(paths); + const fresh = authoritativeState(paths, deps.unknownStateError); + if (fresh.revision !== base.revision || fresh.fingerprint !== base.fingerprint) continue; + const validate = () => assertServiceStateLocksOwned(paths); + publish(authority, serialized, validate); + const committed = authoritativeState([authority]); + if (committed.revision !== next.revision || committed.fingerprint !== serviceStateFingerprint(next)) continue; + for (const mirror of mirrors) { + try { publish(mirror, serialized, validate); } + catch (error) { + (deps.onMirrorError ?? ((path, cause) => console.warn( + `service state committed, but compatibility mirror ${path} could not be refreshed: ${cause instanceof Error ? cause.message : String(cause)}`, + )))(mirror, error); + } + } + return next; } - throw new ServiceStateConflictError(anchor, attempts); - }, deps.lockWaitMs); + throw new ServiceStateConflictError(authority, attempts); + }, { waitMs: deps.lockWaitMs, hooks: deps.lockHooks }), deps.mutationLease); +} + +export interface RemoveServiceStateDeps { + readonly paths?: readonly string[]; + readonly unlink?: (path: string) => void; + readonly lockWaitMs?: number; + readonly lockHooks?: ServiceStateLockHooks; +} + +/** + * Delete mirrors first and the authority last under the same ownership locks. + * + * A crash or mirror error before the final unlink leaves the authority in place, so a stale + * mirror can never become a migration source and resurrect a released desktop claim. + */ +export function removeServiceInstallStateRecords(deps: RemoveServiceStateDeps = {}): void { + const paths = deps.paths ?? serviceStateWritePaths(); + const authority = paths.at(-1); + if (!authority) return; + const unlink = deps.unlink ?? unlinkSync; + withOwnershipMutationLease(paths, () => withServiceStateLocks(paths, () => { + for (const mirror of paths.slice(0, -1)) { + assertServiceStateLocksOwned(paths); + if (existsSync(mirror)) unlink(mirror); + } + assertServiceStateLocksOwned(paths); + if (existsSync(authority)) unlink(authority); + }, { waitMs: deps.lockWaitMs, hooks: deps.lockHooks }), { waitMs: deps.lockWaitMs }); } /** The recorded owner of ONE already-read record, or null. Prefer {@link resolveServiceOwnership}. */ @@ -672,18 +563,91 @@ export function serviceOwnership(state: ServiceInstallState | null = readService * exact demotion the record exists to prevent. Absence is the only thing that may mean no * claim. */ -export type ServiceOwnershipResolution = - | { readonly kind: "none" } - | { readonly kind: "owned"; readonly ownership: ServiceOwnership } +export type ServiceStateResolution = + | { readonly kind: "none"; readonly revision: 0; readonly needsRepair: false } + | { readonly kind: "state"; readonly state: ServiceInstallState; readonly revision: number; readonly needsRepair: boolean } | { readonly kind: "unknown"; readonly reason: string }; +export type ServiceOwnershipSubject = + | { readonly kind: "none"; readonly revision: number } + | { readonly kind: "owned"; readonly ownership: ServiceOwnership; readonly revision: number }; + +export type ServiceOwnershipResolution = ServiceOwnershipSubject + | { readonly kind: "unknown"; readonly reason: string }; + +export function resolveServiceState( + evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(), +): ServiceStateResolution { + const selected = selectAuthoritativeServiceState(evidence as readonly ServiceStateRecordEvidence[]); + if (selected.kind === "unknown") return selected; + if (selected.kind === "none") return selected; + return { + kind: "state", + state: selected.state as ServiceInstallState, + revision: selected.revision, + needsRepair: selected.needsRepair, + }; +} + export function resolveServiceOwnership( evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(), ): ServiceOwnershipResolution { - // The resolution rule is the shared contract's, for the same reason the record contract is: - // the Node launcher decides this question too, and a weaker copy there is an authorization - // gap rather than a style problem. - return resolveOwnershipFromEvidence(evidence) as unknown as ServiceOwnershipResolution; + const state = resolveServiceState(evidence); + if (state.kind === "unknown") return state; + if (state.kind === "none" || !state.state.ownership) return { kind: "none", revision: state.revision }; + return { kind: "owned", ownership: state.state.ownership, revision: state.revision }; +} + +export function sameServiceOwnershipSubject( + left: ServiceOwnershipSubject, + right: ServiceOwnershipSubject, +): boolean { + if (left.kind !== right.kind || left.revision !== right.revision) return false; + if (left.kind === "none" || right.kind === "none") return true; + return left.ownership.owner === right.ownership.owner + && left.ownership.installId === right.ownership.installId + && left.ownership.consentGeneration === right.ownership.consentGeneration; +} + +function sameServiceOwnershipIdentity(left: ServiceOwnershipSubject, right: ServiceOwnershipSubject): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "none" || right.kind === "none") return true; + return left.ownership.owner === right.ownership.owner + && left.ownership.installId === right.ownership.installId + && left.ownership.consentGeneration === right.ownership.consentGeneration; +} + +function serviceOwnershipSubject( + state: ServiceInstallState | null, + revision: number, +): ServiceOwnershipSubject { + return state?.ownership + ? { kind: "owned", ownership: state.ownership, revision } + : { kind: "none", revision }; +} + +export class ServiceOwnershipSubjectMismatchError extends Error { + readonly code = "service-ownership-subject-mismatch" as const; + constructor(readonly expected: ServiceOwnershipSubject, readonly actual: ServiceOwnershipSubject) { + super("service ownership changed after consent; resolve again and ask for fresh approval"); + this.name = "ServiceOwnershipSubjectMismatchError"; + } +} + +export class ServiceOwnershipSubjectUnknownError extends Error { + readonly code = "service-ownership-subject-unknown" as const; + constructor(readonly expected: ServiceOwnershipSubject, readonly reason: string) { + super(`service ownership could not be revalidated after consent (${reason}); nothing was written`); + this.name = "ServiceOwnershipSubjectUnknownError"; + } +} + +export class ServiceTakeoverCompatibilityChangedError extends Error { + readonly code = "service-takeover-compatibility-changed" as const; + constructor(readonly actual: ServiceTakeoverCompatibility) { + super("the managing CLI compatibility changed after consent; resolve again and ask for fresh approval"); + this.name = "ServiceTakeoverCompatibilityChangedError"; + } } /** @@ -742,21 +706,65 @@ function ownershipBaseRecord(current: ServiceInstallState | null): ServiceInstal * generation alone, so every relaunch of an app that already has consent is a no-op on the * number. A different owner or a different install id is a new grant and increments it once. */ +export interface RecordServiceOwnerRequest { + readonly owner: ServiceOwner; + readonly installId: string; + readonly expectedSubject: ServiceOwnershipSubject; + readonly expectedCompatibility: Extract; +} + +export interface RecordServiceOwnerDeps extends ServiceStateSwapDeps { + /** Re-observes BOTH the registered manager and the current PATH manager inside the lock. */ + readonly observeManagers: () => Readonly>; +} + export function recordServiceOwner( - claim: { owner: ServiceOwner; installId: string }, - deps: ServiceStateSwapDeps = {}, -): ServiceOwnership { - if (!claim.installId) throw new Error("refusing to record service ownership without an install id"); + request: RecordServiceOwnerRequest, + deps: RecordServiceOwnerDeps, +): Extract { + if (!request.installId) throw new Error("refusing to record service ownership without an install id"); + if (!request.expectedSubject || request.expectedCompatibility?.kind !== "supported") { + throw new Error("refusing to record service ownership without the exact approved subject and compatibility token"); + } + if (!deps || typeof deps.observeManagers !== "function") { + throw new Error("refusing to record service ownership without a managing-CLI revalidation callback"); + } + const { observeManagers, ...swapDeps } = deps; let recorded: ServiceOwnership | null = null; - swapServiceInstallState(current => { + const committed = swapServiceInstallState((current, context) => { + const actualSubject = serviceOwnershipSubject(current, context.revision); + if (!sameServiceOwnershipSubject(request.expectedSubject, actualSubject)) { + throw new ServiceOwnershipSubjectMismatchError(request.expectedSubject, actualSubject); + } + let managers: Readonly>; + try { managers = observeManagers(); } + catch (error) { + throw new ServiceTakeoverCompatibilityChangedError({ + kind: "blocked", + reason: "managing-cli-unknown", + detail: error instanceof Error ? error.message : String(error), + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }); + } + const compatibility = assessServiceTakeoverCompatibility({ + state: current, + subject: actualSubject, + managers, + }); + if (!sameServiceTakeoverCompatibility(request.expectedCompatibility, compatibility)) { + throw new ServiceTakeoverCompatibilityChangedError(compatibility); + } const previous = current?.ownership ?? null; // The ceiling, not just the live claim: a grant that was released left its number // behind on purpose, so a later grant cannot reuse it. const floor = Math.max(previous?.consentGeneration ?? 0, current?.consentGenerationCeiling ?? 0); + if (floor >= Number.MAX_SAFE_INTEGER) { + throw new Error("service ownership consent generation is exhausted; nothing was written"); + } recorded = { - owner: claim.owner, - installId: claim.installId, - consentGeneration: previous && ownershipGrantedTo(previous, claim.owner, claim.installId) + owner: request.owner, + installId: request.installId, + consentGeneration: previous && ownershipGrantedTo(previous, request.owner, request.installId) ? previous.consentGeneration : floor + 1, }; @@ -765,9 +773,14 @@ export function recordServiceOwner( ownership: recorded, consentGenerationCeiling: Math.max(floor, recorded.consentGeneration), }; - }, deps); - if (recorded === null) throw new Error("service ownership was not recorded"); - return recorded; + }, { + ...swapDeps, + unknownStateError: reason => new ServiceOwnershipSubjectUnknownError(request.expectedSubject, reason), + }); + if (recorded === null || !committed?.ownership || committed.revision === undefined) { + throw new Error("service ownership was not recorded"); + } + return { kind: "owned", ownership: committed.ownership, revision: committed.revision }; } /** @@ -776,9 +789,23 @@ export function recordServiceOwner( * Writes nothing when there is no claim to release, so asking about an unowned runtime never * creates an install record describing a service nobody registered. */ -export function releaseServiceOwner(deps: ServiceStateSwapDeps = {}): ServiceOwnership | null { +export interface ReleaseServiceOwnerDeps extends ServiceStateSwapDeps { + /** Service install refreshes provenance first; that known write may advance only revision. */ + readonly allowRevisionAdvance?: boolean; +} + +export function releaseServiceOwner( + expectedSubject: ServiceOwnershipSubject, + deps: ReleaseServiceOwnerDeps = {}, +): ServiceOwnership | null { + const { allowRevisionAdvance = false, ...swapDeps } = deps; let released: ServiceOwnership | null = null; - swapServiceInstallState(current => { + swapServiceInstallState((current, context) => { + const actualSubject = serviceOwnershipSubject(current, context.revision); + const matches = allowRevisionAdvance + ? sameServiceOwnershipIdentity(expectedSubject, actualSubject) && actualSubject.revision >= expectedSubject.revision + : sameServiceOwnershipSubject(expectedSubject, actualSubject); + if (!matches) throw new ServiceOwnershipSubjectMismatchError(expectedSubject, actualSubject); released = current?.ownership ?? null; if (!current?.ownership) return null; const { ownership: _released, ...withoutOwnership } = current; @@ -792,7 +819,10 @@ export function releaseServiceOwner(deps: ServiceStateSwapDeps = {}): ServiceOwn current.ownership.consentGeneration, ), }; - }, deps); + }, { + ...swapDeps, + unknownStateError: reason => new ServiceOwnershipSubjectUnknownError(expectedSubject, reason), + }); return released; } @@ -806,20 +836,15 @@ export type ServiceStateEvidence = /** * Every state path, with what each one said. * - * `readServiceInstallState` returns the FIRST path that parsed and discards the - * rest, so a valid mirror beside a corrupt one reads as clean. That is the right - * behavior for callers that just need the install state; it is the wrong input - * for deciding ownership, where a disagreement between mirrors is exactly the - * evidence that matters. + * The final path is authoritative; earlier paths are compatibility mirrors and the + * migration source only while the authority is absent. Keeping the raw evidence separate + * lets the selector distinguish migration, degraded mirrors and unordered conflicts. */ export function inspectServiceStateEvidence( paths: readonly string[] = serviceStatePaths(), ): readonly ServiceStateEvidence[] { - // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask, and collapsing - // them into absence is how a locked-down state file would become permission to write. - // The classification is the shared contract's, so the launcher makes the same call. return paths.map(path => ( - inspectInstallStateBytes(path, at => readFileSync(at, "utf8")) as unknown as ServiceStateEvidence + inspectInstallStateBytes(path, at => readFileSync(at, "utf8")) as ServiceStateEvidence )); } diff --git a/src/update/index.ts b/src/update/index.ts index 17ffa6dadaf..a8af29661dc 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -11,6 +11,7 @@ import { readPid, readRuntimePort } from "../config/process-state"; import { pendingTeardownOutstanding } from "../config/pending-teardown"; import type { ServiceOwnership } from "../service/state"; import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; +import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; import { npmInvocation } from "./npm-invocation.mjs"; import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; import { detectInstallFromPath } from "./install-detection.mjs"; @@ -326,14 +327,29 @@ export function checkUpdatePackageIntegrity( * the runtime, and treating it as such is how an unreadable record reactivates the npm * launcher over a takeover the user consented to. */ -async function resolvedRuntimeOwnership(): Promise<{ ownership: ServiceOwnership | null; ownershipUnknown: boolean }> { +interface RuntimeOwnershipObservation { + readonly ownership: ServiceOwnership | null; + readonly ownershipUnknown: boolean; + readonly subjectToken: string; +} + +async function resolvedRuntimeOwnership(): Promise { try { const { resolveServiceOwnership } = await import("../service"); const resolution = resolveServiceOwnership(); - if (resolution.kind === "owned") return { ownership: resolution.ownership, ownershipUnknown: false }; - return { ownership: null, ownershipUnknown: resolution.kind === "unknown" }; + if (resolution.kind === "owned") return { + ownership: resolution.ownership, + ownershipUnknown: false, + subjectToken: JSON.stringify(["owned", resolution.revision, resolution.ownership]), + }; + if (resolution.kind === "none") return { + ownership: null, + ownershipUnknown: false, + subjectToken: JSON.stringify(["none", resolution.revision]), + }; + return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; } catch { - return { ownership: null, ownershipUnknown: true }; + return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; } } @@ -404,11 +420,16 @@ export async function runUpdate(): Promise { } catch { /* best-effort */ } // What this update may do to the runtime. A desktop takeover vetoes both the stop and the // service refresh below; see `planUpdateRuntimeHandling` for why each half is wrong. - let runtimePlan = planUpdateRuntimeHandling({ - ...(await resolvedRuntimeOwnership()), + const initialOwnership = await resolvedRuntimeOwnership(); + const runtimePlan = planUpdateRuntimeHandling({ + ...initialOwnership, serviceInstalled: serviceWasInstalled, }); if (runtimePlan.notice) console.log(runtimePlan.notice); + if (!runtimePlan.mayReplacePackage) { + console.error("⚠️ Update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + process.exit(1); + } let trayWasInstalled = false; let trayWasRunning = false; if (process.platform === "win32") { @@ -456,20 +477,7 @@ export async function runUpdate(): Promise { // silently skips the recovery the receipt was written to trigger (#3008). // Full `ocx stop` semantics (drain, service stop, restore). let stopAttempted = false; - // Re-read at the point of action rather than trusting the plan formed above. Between the - // two the Windows tray handoff spawns children and the listen target is captured, so a - // takeover can land in between — and stopping a runtime that just changed hands is the - // failure this lane exists to prevent. Reassigning the one variable keeps the recovery - // branches and the restart hint reading the same decision as the stop. - { - const atStop = planUpdateRuntimeHandling({ - ...(await resolvedRuntimeOwnership()), - serviceInstalled: serviceWasInstalled, - }); - if (atStop.notice && atStop.notice !== runtimePlan.notice) console.log(atStop.notice); - runtimePlan = atStop; - } - if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { + if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); @@ -530,26 +538,45 @@ export async function runUpdate(): Promise { } } - console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`); - const installStdio = updateChildStdio(); - // Every post-update action below receives this path. For pnpm it is replaced only - // by a path returned after tree+shim verification; on rollback, activePath is - // likewise returned only after the old group has been verified again. - let postUpdateLauncher = join(packageRoot(), "bin", "ocx.mjs"); - // The pnpm owner preflight has verified this package tree and global group. Keep that exact - // package path as the recovery starting point; the path returned by the update transaction - // replaces it only after post-update tree+shim verification succeeds. - if (installer === "pnpm" && owner) { - postUpdateLauncher = join(owner.packagePath, "bin", "ocx.mjs"); - } + let postUpdateLauncher = installer === "pnpm" && owner + ? join(owner.packagePath, "bin", "ocx.mjs") + : join(packageRoot(), "bin", "ocx.mjs"); let postUpdateLauncherUsable = true; let r: { status: number | null; signal?: NodeJS.Signals | null; stdout?: string | Buffer | null; stderr?: string | Buffer | null; - }; + } | null = null; + const { serviceStatePaths } = await import("../service"); + const replacementLease = acquireOwnershipMutationLease(serviceStatePaths()); + let replacementRefusal: string | null = null; + try { + // Ownership can change while registry and stop work is in flight. Unknown at this exact + // boundary blocks replacement; a confirmed desktop claim still permits updating the idle + // npm installation while leaving the bundled sidecar alone. + const replacementOwnership = await resolvedRuntimeOwnership(); + const replacementPlan = planUpdateRuntimeHandling({ + ...replacementOwnership, + serviceInstalled: serviceWasInstalled, + }); + const replacementLiveness = runtimePlan.mayStopRuntime + ? (await proxyIdentityAt(capturedListen.port, { hostname: capturedListen.hostname }) + ? "live" + : probeProxyLiveness(capturedListen.port, capturedListen.hostname)) + : "dead"; + if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + || !replacementPlan.mayReplacePackage + || replacementLiveness !== "dead") { + replacementRefusal = replacementPlan.notice + ?? (replacementLiveness === "live" + ? "⚠️ Update stopped because a proxy became live after the stop decision; rerun from the beginning." + : "⚠️ Update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); + } else { + console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`); + + // Every post-update action below receives the verified active launcher. if (installer === "pnpm") { let update: ReturnType; try { @@ -597,7 +624,26 @@ export async function runUpdate(): Promise { ...target.options, }); } - if (installStdio === "pipe") logSpawnOutput("", r); + if (r && installStdio === "pipe") logSpawnOutput("", r); + } + } finally { + replacementLease.release(); + } + if (replacementRefusal) { + if (trayWasRunning) { + try { + const { startWindowsTray } = await import("../tray/windows"); + startWindowsTray(); + } catch { /* preserve the ownership refusal */ } + } + console.error(replacementRefusal); + process.exit(1); + } + if (!r) throw new Error("update replacement returned no result"); + const postInstallPlan = planUpdateRuntimeHandling({ + ...(await resolvedRuntimeOwnership()), + serviceInstalled: serviceWasInstalled, + }); if (r.status === 0) { console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`); // Re-enter through the verified active package launcher. This keeps the Codex @@ -628,7 +674,7 @@ export async function runUpdate(): Promise { // The stop above unloaded any managed service; repair it with the NEW files // (spawn the fresh cli.ts so updated code writes the baked paths) so a // launchd/schtasks/systemd user isn't left with the background proxy down. - if (runtimePlan.refreshService) { + if (postInstallPlan.mayRestoreService) { console.log("🔁 Refreshing the background service with the updated files..."); const { serviceReinstallArgs } = await import("../service"); const { reclaimListenPort } = await import("../server/port-reclaim"); @@ -685,7 +731,7 @@ export async function runUpdate(): Promise { ...(await resolvedRuntimeOwnership()), serviceInstalled: true, }); - if (!nowOwned.stopRuntime) { + if (!nowOwned.mayStopRuntime) { console.warn(nowOwned.notice ?? "⚠️ The background runtime is owned elsewhere; not starting a second proxy."); return; } @@ -716,20 +762,20 @@ export async function runUpdate(): Promise { if (prevBake === undefined) delete process.env.OCX_BAKE_PORT; else process.env.OCX_BAKE_PORT = prevBake; } - } else if (runtimePlan.stopRuntime) { + } else if (postInstallPlan.mayStopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); } } else { if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) { spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true }); } - if (stopAttempted && runtimePlan.refreshService && postUpdateLauncherUsable) { + if (stopAttempted && postInstallPlan.mayRestoreService && postUpdateLauncherUsable) { const service = spawnSync(process.execPath, [postUpdateLauncher, "service", "repair"], { stdio: "inherit", windowsHide: true, }); if (service.status !== 0) console.warn("⚠️ Previous background service could not be restored; run 'ocx service repair'."); - } else if (stopAttempted && postUpdateLauncherUsable) { + } else if (stopAttempted && postUpdateLauncherUsable && postInstallPlan.mayStopRuntime) { const env = { ...process.env }; delete env.OCX_SERVICE; const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { @@ -739,6 +785,8 @@ export async function runUpdate(): Promise { env: withProcessRuntimeProvenance(env), }); child.unref(); + } else if (stopAttempted && !postInstallPlan.mayStopRuntime) { + console.warn(postInstallPlan.notice ?? "⚠️ Runtime ownership changed during the update; not starting a second proxy."); } else if (stopAttempted) { console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); } diff --git a/src/update/job.ts b/src/update/job.ts index d3ce29ec9be..dd8e47748e5 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -26,7 +26,7 @@ import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop"; import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness"; import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service"; -import { updateRestartVeto, type ServiceOwnershipResolution } from "./restart-ownership"; +import { runUpdateRestartWithOwnershipLease, type ServiceOwnershipResolution } from "./restart-ownership"; import { type Channel, type Installer, @@ -1970,15 +1970,12 @@ export async function runGuiUpdateWorker( } if (restart) { - // The package updater it just ran deliberately left a foreign-owned runtime alone, and - // restarting here would replace the app's sidecar with an npm proxy. - const veto = updateRestartVeto(io.resolveOwnershipFn); - if (veto) { updateJob(job, { status: "succeeded", restarted: false }, veto); return; } - job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); - if (!(await finishGuiUpdateRestart(job, captured, check.installer, { - ...io.restartIo, - packageLauncherPathFn: () => activeLauncher, - }))) return; + const outcome = await runUpdateRestartWithOwnershipLease(io.resolveOwnershipFn, async () => { + job = updateJob(job!, { status: "restarting" }, "Update installed. Restarting proxy..."); + return finishGuiUpdateRestart(job!, captured, check.installer, { ...io.restartIo, packageLauncherPathFn: () => activeLauncher }); + }); + if (outcome.kind === "veto") { updateJob(job, { status: "succeeded", restarted: false }, outcome.notice); return; } + if (!outcome.value) return; updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy."); return; } diff --git a/src/update/restart-ownership.ts b/src/update/restart-ownership.ts index 7e0471e379d..32cafee1e6e 100644 --- a/src/update/restart-ownership.ts +++ b/src/update/restart-ownership.ts @@ -1,5 +1,10 @@ import { resolveServiceOwnership } from "../service"; import type { ServiceOwnershipResolution } from "../service"; +import { serviceStatePaths } from "../service/state"; +import { + acquireOwnershipMutationLease, + OWNERSHIP_MUTATION_LEASE_TOKEN_ENV, +} from "../service/ownership-mutation-lease.mjs"; import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; export type { ServiceOwnershipResolution }; @@ -27,6 +32,23 @@ export function updateRestartVeto( // The restart decision does not refresh the service; only the stop veto is read here. serviceInstalled: false, }); - if (plan.stopRuntime) return null; + if (plan.mayStopRuntime) return null; return plan.notice ?? "The background runtime is owned elsewhere; it was left running."; } + +export async function runUpdateRestartWithOwnershipLease( + resolve: (() => ServiceOwnershipResolution) | undefined, + restart: () => Promise, +): Promise<{ readonly kind: "veto"; readonly notice: string } | { readonly kind: "ran"; readonly value: T }> { + const lease = acquireOwnershipMutationLease(serviceStatePaths()); + const previous = process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV] = lease.token; + try { + const veto = updateRestartVeto(resolve); + return veto ? { kind: "veto", notice: veto } : { kind: "ran", value: await restart() }; + } finally { + if (previous === undefined) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + else process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV] = previous; + lease.release(); + } +} diff --git a/src/update/runtime-ownership.d.mts b/src/update/runtime-ownership.d.mts index cc9b74fd853..12762895238 100644 --- a/src/update/runtime-ownership.d.mts +++ b/src/update/runtime-ownership.d.mts @@ -1,10 +1,10 @@ -/** Declaration for the plain-ESM runtime-ownership rule shared with `bin/ocx.mjs`. */ export declare function planUpdateRuntimeHandling(input: { ownership: { owner: string; installId: string; consentGeneration: number } | null; ownershipUnknown?: boolean; serviceInstalled: boolean; }): { - stopRuntime: boolean; - refreshService: boolean; + mayReplacePackage: boolean; + mayStopRuntime: boolean; + mayRestoreService: boolean; notice: string | null; }; diff --git a/src/update/runtime-ownership.mjs b/src/update/runtime-ownership.mjs index 0e7fba1341f..432f223bc73 100644 --- a/src/update/runtime-ownership.mjs +++ b/src/update/runtime-ownership.mjs @@ -7,17 +7,6 @@ * situation separately is how a fix ships on one side only. */ -/* - * There is deliberately no ownership PARSER here any more. - * - * This module used to carry one so the Node launcher could read the record without importing - * TypeScript, kept "in step" with the authoritative reader by a test that drove the same - * shapes through both. It was not in step: it inspected one path and treated a record that - * failed the whole install-state contract as an unowned runtime whenever its `ownership` - * field was simply absent. Reading and resolving now live in - * `src/service/install-state-contract.mjs`, which both runtimes import, so there is one - * algorithm rather than two that a test has to keep aligned. - */ /** * Decide how an update treats a runtime it may not own. * @@ -38,25 +27,24 @@ * two-record ownership design accepted; `ocx service install` clears the marker and restores * the ordinary path. * - * Both returned flags are VETOES, not commands: each updater already has its own reasons to - * stop the proxy and to refresh the service, and this plan can only take them away. + * The three returned flags are separate authorities, not commands. In particular, leaving + * a runtime running is not permission to replace the package it may be executing from. * - * THE LIMIT OF THIS RULE. It reads the recorded claim, not the live process. If the app was - * deleted and the user then starts an npm proxy by hand, the stale claim still vetoes the - * stop and the update replaces package files under a live server. Proving WHICH runtime is - * answering needs the identity the bundled CLI's resolve contract will carry; until then the - * notice tells the user how to clear the marker. + * A recorded desktop claim does not prove which binary is live. Until the bundled resolver + * carries installation identity, it therefore blocks package replacement as well as stop and + * restoration; the notice tells a stale-marker user how to take ownership back explicitly. * * @param {{ ownership: { owner: string, installId: string, consentGeneration: number } | null, ownershipUnknown?: boolean, serviceInstalled: boolean }} input - * @returns {{ stopRuntime: boolean, refreshService: boolean, notice: string | null }} + * @returns {{ mayReplacePackage: boolean, mayStopRuntime: boolean, mayRestoreService: boolean, notice: string | null }} */ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, serviceInstalled }) { // Unreadable, malformed or contradictory is not "nobody owns it". Reading it that way is // how a permissions error reactivates the npm launcher over a consented takeover. if (ownershipUnknown) { return { - stopRuntime: false, - refreshService: false, + mayReplacePackage: false, + mayStopRuntime: false, + mayRestoreService: false, notice: "⚠️ The background runtime's recorded owner could not be determined, so it was " + "left running and the service registration was not touched. " + "Run 'ocx service install' to re-register the service and take the runtime back.", @@ -67,13 +55,21 @@ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, // registration. if (ownership && ownership.owner !== "cli") { return { - stopRuntime: false, - refreshService: false, + // A claim does not prove which binary is live. A stale desktop marker beside a + // manually started npm proxy would otherwise replace that proxy's executing files. + mayReplacePackage: false, + mayStopRuntime: false, + mayRestoreService: false, notice: `🖥️ The desktop app owns the background runtime (install ${ownership.installId}, ` - + `consent generation ${ownership.consentGeneration}). It was left running, and the ` + + `consent generation ${ownership.consentGeneration}). It and the npm package were left unchanged, and the ` + "service registration was neither re-enabled nor restarted. " + "If the desktop app is gone, run 'ocx service install' to take the runtime back.", }; } - return { stopRuntime: true, refreshService: serviceInstalled, notice: null }; + return { + mayReplacePackage: true, + mayStopRuntime: true, + mayRestoreService: serviceInstalled, + notice: null, + }; } diff --git a/structure/runtime.md b/structure/runtime.md index 438e95547c6..e975d9129ea 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -530,64 +530,49 @@ opaque `installId` naming the owning installation rather than the user or the ma `consentGeneration`. An absent claim means the CLI install that registered the service owns the runtime, which is what every record written before the field existed says. -`src/service/install-state-contract.mjs` holds the record shape, the path list and the -resolution rule, and both runtimes import it: `src/service/state.ts` and the Node launcher -`bin/ocx.mjs`, which cannot import TypeScript. The launcher previously kept its own reader, -and the divergence was an authorization gap rather than a style problem — it inspected only -the anchor path and answered "unowned" for any record whose `ownership` field was absent, -including one that failed the contract outright. - -Every write goes through `swapServiceInstallState`. It holds an `O_EXCL` lock beside the -anchor record for the whole read-modify-write, re-reads the anchor immediately before -committing and compares the committed bytes afterwards, and it runs the whole sequence again -when another writer landed inside that window; `revision` is the compare-and-swap token. The -lock excludes cooperating writers, and the revision check catches a writer that does not take -it, such as an older `ocx` on the same machine. The lock file carries a token identifying its -holder, so eviction and release each remove only the instance they own, and the stale -threshold exceeds the longest legitimate critical section rather than the typical one. Each -file is published by writing a sibling temporary file and renaming it, so an interrupted -commit leaves the previous valid record rather than a truncated one the fail-closed reader -would report as unknown. - -`writeServiceInstallState` rebuilds only the install provenance and carries the ownership -claim across unchanged, which is what keeps an install, a repair, an update or a stop from -dropping it. It resolves that claim INSIDE the swap, while the lock is held: a resolution -taken beforehand is a lost update the compare-and-swap cannot detect, because the stale value -never came from the base record. Where the anchor and the cross-path resolution still -disagree, the higher `consentGeneration` wins and an equal generation keeps the anchor. - -`resolveServiceOwnership` is how a claim is read for a decision. It reads every state path -and answers `none`, `owned` or `unknown`; absence is the only thing that means no claim, so -an unreadable path, a corrupt anchor record, or paths naming different owners all refuse -rather than reading as CLI-owned. `consentGenerationCeiling` survives a release, so granting, -releasing and granting again cannot reuse a number an app-local record may still hold. - -`recordServiceOwner` is idempotent on the same owner and install id, so a relaunch leaves the -generation alone and a grant moves it exactly once. -`ownershipGrantedTo(ownership, owner, installId)` is the comparison an installation applies -to its own locally stored install id: true means this installation already holds consent, -false against a recorded claim means a different installation owns the runtime and consent -has to be asked again, and a null claim means the CLI install still owns it. - -The verbs that ACTIVATE the npm registration refuse on a foreign or unknown owner: +Every write goes through `swapServiceInstallState`. With a custom home, the default-home +record is the authority every writer can derive and the active-home record is a compatibility +mirror; with one path, that path is authoritative. `src/service/state-lock.ts` holds +token/PID/process-instance locks for every path in canonical order. A live holder is never +evicted because of age, and release deletes only its token-named owner. The authoritative +file is fsynced and atomically renamed through `src/config/atomic-write.ts`; that rename is +the commit point. Mirrors receive the exact committed bytes afterwards. A mirror failure is +diagnostic rather than rollback, and the next writer repairs it. An absent authority imports +one valid legacy mirror once; same-or-newer mirror disagreement and unreadable authority are +`unknown`, never ownership votes. Uninstall removes mirrors before the authority, so a +partial deletion cannot turn a revoked mirror claim back into migration input. + +`resolveServiceOwnership` answers `none`, `owned` or `unknown` from that authoritative +generation. `consentGenerationCeiling` survives a release, so granting, releasing and +granting again cannot reuse a number an app-local record may still hold. +`recordServiceOwner` requires the exact `owner`/`installId`/`consentGeneration`/`revision` +subject shown on the consent surface. The comparison runs again inside the same lock and on +every internal retry; a mismatch or unknown subject writes nothing and requires fresh user +approval. `ownershipGrantedTo` remains the narrower relaunch test for an already-owned app. + +Permanent takeover also requires `assessServiceTakeoverCompatibility` to approve both the +preserved service launcher and the selected PATH launcher. Every observed manager must be +OpenCodex 2.61.0 or later, and a preserved registration must carry ownership protocol 1. +Missing, old, malformed or unknown manager evidence blocks takeover and leaves registration +and autostart untouched. The supported verdict carries an opaque token over the approved +subject and both manager identities; `recordServiceOwner` re-observes and compares it inside +the lock, so a mutable shim or downgrade cannot inherit earlier consent. An upgrade is a +separate user-authorized action; declining or failing it leaves the app a guest. + +The verbs that activate the npm registration refuse on a foreign or unknown owner: `src/service/repair.ts` stops before it asserts, writes, stops or starts anything, and `ocx service start` reports the same refusal. `stop` and `uninstall` are not gated, because they deactivate. `src/update/runtime-ownership.mjs` vetoes both the pre-update stop and the post-update service refresh for all three update lanes — `src/update/index.ts`, -`bin/ocx.mjs` and the dashboard worker in `src/update/job.ts` — and the two package updaters -re-read the claim immediately before each runtime action — the stop and the direct-start -fallback — rather than trusting a plan formed earlier in the run, because an app can take the -runtime while the tray handoff spawns children or an install runs for minutes. The -registration is never deleted; `ocx service install` is the one verb that releases the -marker, and it does so only after the registration succeeded. - -The veto reads the recorded claim, not the live process. An app removed without releasing -leaves a stale claim, and proving which runtime is answering needs the identity the bundled -CLI's resolve contract will carry; until then the refusals name `ocx service install` as the -way to clear it. - -Re-reading narrows the window between a decision and its action; it does not remove it. A -claim recorded after the last read and before the child process starts is still acted on with -stale information. Closing that needs an action-scoped ownership lease held across the child, -which the state lock deliberately is not — holding it across `ocx stop` or a service refresh -would deadlock against the child's own write. +`bin/ocx.mjs` and the dashboard worker in `src/update/job.ts`. The shared update decision has +three independent authorities: package replacement, runtime stop and service restoration. +Unknown and desktop ownership deny all three because a claim alone does not prove that the live +process is detached from the npm package; CLI ownership permits the ordinary stop-first +flow. Both package updaters use `src/service/install-state-contract.mjs`, backed by the single +`state-record.mjs` parser and authority selector. A shared mutation lease covers the final subject/liveness +recheck through package replacement, and service install/start plus every state mutation take +the same lease, so the stop decision cannot go stale under a cooperating manager. Dashboard +restart keeps that lease through port reclaim and delegates its token to the repair child; +direct start takes it again for the final liveness check through successful bind. +The registration is never deleted; `ocx service install` releases the marker only after the +registration succeeds. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a7a7fcee72f..d08b15a92d9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1208,6 +1208,7 @@ "server-xai-header-parity.test.ts": "server", "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", + "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", diff --git a/tests/service/service-ownership-compatibility.test.ts b/tests/service/service-ownership-compatibility.test.ts new file mode 100644 index 00000000000..6a216aa3be3 --- /dev/null +++ b/tests/service/service-ownership-compatibility.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { + assessServiceTakeoverCompatibility, + registeredManagingCliInvocation, + type ManagingCliObservation, +} from "../../src/service/ownership-compatibility"; +import type { ServiceInstallState, ServiceOwnershipSubject } from "../../src/service/state"; + +const SUBJECT: ServiceOwnershipSubject = { kind: "none", revision: 7 }; +const OWNERSHIP_AWARE_STATE: ServiceInstallState = { + version: 2, + codexHome: "/codex", + opencodexHome: "/opencodex", + backend: "scheduler", + revision: 7, + ownershipProtocolVersion: 1, +}; + +const absent: ManagingCliObservation = { status: "absent" }; +const observed = (version: string, identity = `manager-${version}`): ManagingCliObservation => ({ + status: "observed", version, identity, +}); + +function assess(options: { + state?: ServiceInstallState | null; + service?: ManagingCliObservation; + path?: ManagingCliObservation; +} = {}) { + return assessServiceTakeoverCompatibility({ + state: options.state === undefined ? OWNERSHIP_AWARE_STATE : options.state, + subject: SUBJECT, + managers: { + "service-registration": options.service ?? observed("2.61.0", "registered-manager"), + path: options.path ?? observed("2.61.0", "path-manager"), + }, + }); +} + +describe("permanent takeover compatibility", () => { + test("the preserved registration resolves to the exact baked invocation", () => { + expect(registeredManagingCliInvocation({ + ...OWNERSHIP_AWARE_STATE, + bunPath: "/runtime/bun", + cliPath: "/package/src/cli/index.ts", + })).toEqual({ + status: "resolved", executable: "/runtime/bun", args: ["/package/src/cli/index.ts"], + }); + expect(registeredManagingCliInvocation({ + ...OWNERSHIP_AWARE_STATE, + launcherPath: "/bin/ocx", + })).toEqual({ status: "resolved", executable: "/bin/ocx", args: [] }); + }); + + test("every managing CLI must be ownership-aware", () => { + expect(assess({ path: observed("2.60.0") })).toMatchObject({ + kind: "blocked", reason: "managing-cli-unsupported", + }); + expect(assess({ service: observed("2.61.0"), path: observed("2.62.0") })).toMatchObject({ + kind: "supported", protocolVersion: 1, + }); + }); + + test("unknown, malformed and prerelease observations do not authorize takeover", () => { + expect(assess({ path: { status: "unknown", reason: "probe timed out" } })).toMatchObject({ + kind: "blocked", reason: "managing-cli-unknown", + }); + for (const version of ["garbage", "2.61.0-preview.1", "2.60.99"]) { + expect(assess({ path: observed(version) })).toMatchObject({ + kind: "blocked", reason: "managing-cli-unsupported", + }); + } + }); + + test("a preserved service registration needs the state protocol marker too", () => { + expect(assess({ state: { ...OWNERSHIP_AWARE_STATE, ownershipProtocolVersion: undefined } })) + .toMatchObject({ kind: "blocked", reason: "service-protocol-unsupported" }); + expect(assess({ state: null, service: absent, path: observed("2.61.0") })) + .toMatchObject({ kind: "supported" }); + }); + + test("an unobserved manager set cannot retroactively protect an older CLI", () => { + expect(assess({ state: null, service: absent, path: absent })).toMatchObject({ + kind: "blocked", reason: "managing-cli-unobserved", + }); + }); + + test("the compatibility token binds both manager identities and the approved subject", () => { + const original = assess(); + const replacedPath = assess({ path: observed("2.61.0", "different-path-manager") }); + const differentSubject = assessServiceTakeoverCompatibility({ + state: OWNERSHIP_AWARE_STATE, + subject: { kind: "none", revision: 8 }, + managers: { + "service-registration": observed("2.61.0", "registered-manager"), + path: observed("2.61.0", "path-manager"), + }, + }); + expect(original.kind).toBe("supported"); + expect(replacedPath.kind).toBe("supported"); + expect(differentSubject.kind).toBe("supported"); + if (original.kind === "supported" && replacedPath.kind === "supported" && differentSubject.kind === "supported") { + expect(replacedPath.token).not.toBe(original.token); + expect(differentSubject.token).not.toBe(original.token); + } + }); +}); diff --git a/tests/service/service-ownership-handover.test.ts b/tests/service/service-ownership-handover.test.ts index f482f86b17d..c79a907711a 100644 --- a/tests/service/service-ownership-handover.test.ts +++ b/tests/service/service-ownership-handover.test.ts @@ -20,7 +20,7 @@ const INSTALLED: ServiceDiagnostic = { }; const DESKTOP_CLAIM = { owner: "desktop", installId: "app-install-a", consentGeneration: 2 } as const; -const DESKTOP: ServiceOwnershipResolution = { kind: "owned", ownership: DESKTOP_CLAIM }; +const DESKTOP: ServiceOwnershipResolution = { kind: "owned", ownership: DESKTOP_CLAIM, revision: 4 }; const UNKNOWN: ServiceOwnershipResolution = { kind: "unknown", reason: "a service state path could not be read (EACCES)" }; describe("repair under an owner that is not this CLI", () => { @@ -84,8 +84,8 @@ describe("repair under an owner that is not this CLI", () => { test("a CLI owner repairs normally, and so does a record with no claim at all", async () => { const resolutions: ServiceOwnershipResolution[] = [ - { kind: "none" }, - { kind: "owned", ownership: { owner: "cli", installId: "npm-install", consentGeneration: 4 } }, + { kind: "none", revision: 0 }, + { kind: "owned", ownership: { owner: "cli", installId: "npm-install", consentGeneration: 4 }, revision: 8 }, ]; for (const resolution of resolutions) { let repaired = false; @@ -122,10 +122,10 @@ describe("which service verbs are gated", () => { * repair would reactivate it. */ test("install releases the marker only after the registration succeeded", () => { - expect(installCase).toContain("releaseServiceOwner()"); - expect(installCase.indexOf("installServiceSafely")).toBeLessThan(installCase.indexOf("releaseServiceOwner()")); + expect(installCase).toContain("releaseServiceOwner(ownershipBeforeInstall"); + expect(installCase.indexOf("installServiceSafely")).toBeLessThan(installCase.indexOf("releaseServiceOwner(ownershipBeforeInstall")); // The failure branch leaves before the release. - expect(installCase.indexOf("Service install cleanup failed")).toBeLessThan(installCase.indexOf("releaseServiceOwner()")); + expect(installCase.indexOf("Service install cleanup failed")).toBeLessThan(installCase.indexOf("releaseServiceOwner(ownershipBeforeInstall")); }); test("start refuses on the same terms, because it activates the registration", () => { @@ -144,4 +144,3 @@ describe("which service verbs are gated", () => { expect(readFileSync(repoPath("src", "service", "repair.ts"), "utf8")).not.toContain("releaseServiceOwner"); }); }); - diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index 3de8dd9b90e..4cac1931916 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -8,7 +8,7 @@ * doctor suggestion — and nothing said it had gone. */ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { createTempHome, type TempHome } from "../helpers/temp-home"; import { repoPath } from "../helpers/repo-root"; import { @@ -20,14 +20,22 @@ import { readServiceInstallState, recordServiceOwner, releaseServiceOwner, + removeServiceInstallStateRecords, resolveServiceOwnership, + resolveServiceState, + ServiceOwnershipSubjectMismatchError, + ServiceTakeoverCompatibilityChangedError, ServiceStateConflictError, serviceOwnership, serviceStatePath, serviceStatePaths, swapServiceInstallState, writeServiceInstallState, + type RecordServiceOwnerDeps, + type ServiceOwner, + type ServiceStateSwapDeps, } from "../../src/service/state"; +import { assessServiceTakeoverCompatibility, type ManagingCliObservation } from "../../src/service/ownership-compatibility"; let home: TempHome; /** @@ -57,26 +65,63 @@ afterEach(() => { }); const DESKTOP = { owner: "desktop", installId: "app-install-a" } as const; +const COMPATIBLE_MANAGERS: Readonly> = { + "service-registration": { status: "absent" }, + path: { status: "observed", version: "2.61.0", identity: "path-manager-a" }, +}; + +function grantServiceOwner( + claim: { owner: ServiceOwner; installId: string }, + deps: ServiceStateSwapDeps & Pick, "observeManagers"> = {}, +) { + const request = approvedOwnerRequest(claim); + const { observeManagers = () => COMPATIBLE_MANAGERS, ...swapDeps } = deps; + return recordServiceOwner(request, { ...swapDeps, observeManagers }); +} + +function approvedOwnerRequest( + claim: { owner: ServiceOwner; installId: string }, + managers = COMPATIBLE_MANAGERS, +) { + const expectedSubject = resolveServiceOwnership(); + if (expectedSubject.kind === "unknown") throw new Error(expectedSubject.reason); + const resolved = resolveServiceState(); + if (resolved.kind === "unknown") throw new Error(resolved.reason); + const expectedCompatibility = assessServiceTakeoverCompatibility({ + state: resolved.kind === "state" ? resolved.state : null, + subject: expectedSubject, + managers, + }); + if (expectedCompatibility.kind !== "supported") throw new Error(expectedCompatibility.detail); + return { ...claim, expectedSubject, expectedCompatibility }; +} + +function releaseCurrentOwner(deps: ServiceStateSwapDeps = {}) { + const expected = resolveServiceOwnership(); + if (expected.kind === "unknown") throw new Error(expected.reason); + return releaseServiceOwner(expected, deps); +} describe("ownership survives every install-state writer", () => { test("a repair over a desktop takeover keeps the owner, the install id and the generation", () => { - const claimed = recordServiceOwner(DESKTOP); - expect(claimed).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); + const claimed = grantServiceOwner(DESKTOP); + expect(claimed.ownership).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); // What a repair does: rebuild the install provenance and write it. writeServiceInstallState("scheduler", null); const after = readServiceInstallState(); - expect(after?.ownership).toEqual(claimed); + expect(after?.ownership).toEqual(claimed.ownership); // The provenance half really was refreshed, so this is preservation rather than a // write that quietly did nothing. expect(after?.bunPath).toBeTruthy(); expect(after?.backend).toBe("scheduler"); + expect(after?.ownershipProtocolVersion).toBe(1); expect(desktopOwnsService()).toBe(true); }); test("a native-backend switch preserves the claim too", () => { - recordServiceOwner(DESKTOP); + grantServiceOwner(DESKTOP); writeServiceInstallState("native"); const after = readServiceInstallState(); expect(after?.backend).toBe("native"); @@ -112,14 +157,14 @@ describe("ownership survives every install-state writer", () => { describe("consent generation and the comparison rule", () => { test("a grant increments once; the same installation relaunching does not", () => { - expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); - expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); - expect(recordServiceOwner({ owner: "desktop", installId: "app-install-b" }).consentGeneration).toBe(2); - expect(recordServiceOwner({ owner: "cli", installId: "app-install-b" }).consentGeneration).toBe(3); + expect(grantServiceOwner(DESKTOP).ownership.consentGeneration).toBe(1); + expect(grantServiceOwner(DESKTOP).ownership.consentGeneration).toBe(1); + expect(grantServiceOwner({ owner: "desktop", installId: "app-install-b" }).ownership.consentGeneration).toBe(2); + expect(grantServiceOwner({ owner: "cli", installId: "app-install-b" }).ownership.consentGeneration).toBe(3); }); test("a grant belongs to one installation, not to the kind of owner", () => { - const ownership = recordServiceOwner(DESKTOP); + const ownership = grantServiceOwner(DESKTOP).ownership; expect(ownershipGrantedTo(ownership, "desktop", "app-install-a")).toBe(true); // A reinstalled app carries a different id and must ask for consent again. expect(ownershipGrantedTo(ownership, "desktop", "app-install-b")).toBe(false); @@ -128,16 +173,16 @@ describe("consent generation and the comparison rule", () => { }); test("an install id is required, because an empty one would match nothing and claim everything", () => { - expect(() => recordServiceOwner({ owner: "desktop", installId: "" })).toThrow(/install id/); + expect(() => grantServiceOwner({ owner: "desktop", installId: "" })).toThrow(/install id/); }); test("releasing returns the dropped claim and creates no record when there is none", () => { - expect(releaseServiceOwner()).toBeNull(); + expect(releaseCurrentOwner()).toBeNull(); expect(existsSync(serviceStatePath())).toBe(false); writeServiceInstallState("scheduler", null); - recordServiceOwner(DESKTOP); - expect(releaseServiceOwner()).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); + grantServiceOwner(DESKTOP); + expect(releaseCurrentOwner()).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); expect(serviceOwnership()).toBeNull(); expect(desktopOwnsService()).toBe(false); // The install record itself is untouched: releasing ownership is not an uninstall. @@ -145,7 +190,7 @@ describe("consent generation and the comparison rule", () => { }); test("claiming with no install state writes no install provenance it cannot vouch for", () => { - recordServiceOwner(DESKTOP); + grantServiceOwner(DESKTOP); const record = readServiceInstallState(); expect(record?.ownership?.owner).toBe("desktop"); expect(record?.bunPath).toBeUndefined(); @@ -154,6 +199,93 @@ describe("consent generation and the comparison rule", () => { }); describe("the compare-and-swap", () => { + test("a revocation that lands while a provenance writer waits is not resurrected", () => { + grantServiceOwner(DESKTOP); + let revoked = false; + writeServiceInstallState("scheduler", null, { + beforeCommit: attempt => { + if (attempt === 0 && !revoked) { + revoked = true; + releaseCurrentOwner(); + } + }, + }); + expect(readServiceInstallState()?.ownership).toBeUndefined(); + expect(readServiceInstallState()?.consentGenerationCeiling).toBe(1); + }); + + test("authority commit survives a mirror failure and the next writer repairs the mirror", () => { + const mirror = home.path("active", "service-state.json"); + const authority = home.path("default", "service-state.json"); + mkdirSync(home.path("active"), { recursive: true }); + mkdirSync(home.path("default"), { recursive: true }); + const initial = { + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", + revision: 1, ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 1 }, + consentGenerationCeiling: 1, + } as const; + const bytes = `${JSON.stringify(initial, null, 2)}\n`; + writeFileSync(mirror, bytes); + writeFileSync(authority, bytes); + const degraded: string[] = []; + releaseServiceOwner({ kind: "owned", ownership: initial.ownership, revision: 1 }, { + paths: [mirror, authority], + commitStateFile: (path, serialized, validate) => { + validate(); + if (path === mirror) throw new Error("mirror unavailable"); + writeFileSync(path, serialized); + }, + onMirrorError: path => degraded.push(path), + }); + expect(degraded).toEqual([mirror]); + expect(resolveServiceOwnership(inspectServiceStateEvidence([mirror, authority]))) + .toEqual({ kind: "none", revision: 2 }); + expect(JSON.parse(readFileSync(mirror, "utf8")).ownership.installId).toBe("app-install-a"); + + writeServiceInstallState("scheduler", null, { paths: [mirror, authority] }); + expect(readFileSync(mirror, "utf8")).toBe(readFileSync(authority, "utf8")); + expect(resolveServiceOwnership(inspectServiceStateEvidence([mirror, authority])).kind).toBe("none"); + }); + + test("an authority publication failure leaves both prior records unchanged", () => { + const mirror = home.path("active-state.json"); + const authority = home.path("authority-state.json"); + const initial = JSON.stringify({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", revision: 4, + }); + writeFileSync(mirror, initial); + writeFileSync(authority, initial); + expect(() => swapServiceInstallState(current => ({ ...current!, launcherPath: "/next/ocx" }), { + paths: [mirror, authority], + commitStateFile: () => { throw new Error("rename refused"); }, + })).toThrow(/rename refused/); + expect(readFileSync(mirror, "utf8")).toBe(initial); + expect(readFileSync(authority, "utf8")).toBe(initial); + }); + + test("a mirror deletion failure keeps the authority, so a revoked claim cannot migrate back", () => { + const mirror = home.path("active-delete.json"); + const authority = home.path("authority-delete.json"); + writeFileSync(mirror, JSON.stringify({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", revision: 4, + ownership: { owner: "desktop", installId: "revoked", consentGeneration: 1 }, + })); + writeFileSync(authority, JSON.stringify({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", revision: 5, + consentGenerationCeiling: 1, + })); + expect(() => removeServiceInstallStateRecords({ + paths: [mirror, authority], + unlink: path => { + if (path === mirror) throw new Error("mirror delete refused"); + unlinkSync(path); + }, + })).toThrow(/mirror delete refused/); + expect(existsSync(authority)).toBe(true); + expect(resolveServiceOwnership(inspectServiceStateEvidence([mirror, authority]))) + .toEqual({ kind: "none", revision: 5 }); + }); + test("a writer that lands inside the commit window is detected and the swap recomputes", () => { writeServiceInstallState("scheduler", null); const before = readServiceInstallState()?.revision ?? 0; @@ -161,7 +293,7 @@ describe("the compare-and-swap", () => { const result = swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { beforeCommit: attempt => { // Exactly one interleaved writer, on the first attempt only. - if (attempt === 0) recordServiceOwner(DESKTOP); + if (attempt === 0) grantServiceOwner(DESKTOP); }, }); @@ -178,7 +310,7 @@ describe("the compare-and-swap", () => { let competitors = 0; expect(() => swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { attempts: 3, - beforeCommit: () => { competitors += 1; recordServiceOwner({ owner: "desktop", installId: "app-" + competitors }); }, + beforeCommit: () => { competitors += 1; grantServiceOwner({ owner: "desktop", installId: "app-" + competitors }); }, })).toThrow(ServiceStateConflictError); expect(competitors).toBe(3); @@ -202,6 +334,17 @@ describe("the compare-and-swap", () => { expect(readFileSync(serviceStatePath(), "utf8")).toBe(before); }); + test("exhausted revision refuses before publishing an unreadable successor", () => { + writeFileSync(serviceStatePath(), JSON.stringify({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, + backend: "scheduler", revision: Number.MAX_SAFE_INTEGER, + })); + const before = readFileSync(serviceStatePath(), "utf8"); + expect(() => swapServiceInstallState(current => ({ ...current!, launcherPath: "/next/ocx" }))) + .toThrow(/revision is exhausted/); + expect(readFileSync(serviceStatePath(), "utf8")).toBe(before); + }); + /** * Unreadable is not absent. Reading a directory is the portable way to produce that * answer; a real one is a permission the process does not have. Either way the swap has @@ -217,6 +360,59 @@ describe("the compare-and-swap", () => { }); }); +describe("the consented subject is a precondition", () => { + test("a delayed service-install release cannot delete a successor claim", () => { + const first = grantServiceOwner(DESKTOP); + const successor = grantServiceOwner({ owner: "desktop", installId: "app-install-b" }); + expect(() => releaseServiceOwner(first, { allowRevisionAdvance: true })) + .toThrow(ServiceOwnershipSubjectMismatchError); + expect(resolveServiceOwnership()).toEqual(successor); + }); + + test("a revocation during the internal retry invalidates the approval", () => { + grantServiceOwner(DESKTOP); + const request = approvedOwnerRequest({ owner: "desktop", installId: "app-install-b" }); + let revoked = false; + expect(() => recordServiceOwner(request, { + observeManagers: () => COMPATIBLE_MANAGERS, + beforeCommit: attempt => { + if (attempt === 0 && !revoked) { + revoked = true; + releaseCurrentOwner(); + } + }, + })).toThrow(ServiceOwnershipSubjectMismatchError); + expect(resolveServiceOwnership().kind).toBe("none"); + }); + + test("a revision-only change requires fresh approval even when the owner is unchanged", () => { + writeServiceInstallState("scheduler", null); + const request = approvedOwnerRequest(DESKTOP); + let changed = false; + expect(() => recordServiceOwner(request, { + observeManagers: () => COMPATIBLE_MANAGERS, + beforeCommit: attempt => { + if (attempt === 0 && !changed) { + changed = true; + writeServiceInstallState("scheduler", null); + } + }, + })).toThrow(ServiceOwnershipSubjectMismatchError); + expect(readServiceInstallState()?.ownership).toBeUndefined(); + }); + + test("managing CLI compatibility is re-observed immediately before the grant", () => { + const request = approvedOwnerRequest(DESKTOP); + expect(() => recordServiceOwner(request, { + observeManagers: () => ({ + "service-registration": { status: "absent" }, + path: { status: "observed", version: "2.60.0", identity: "path-manager-old" }, + }), + })).toThrow(ServiceTakeoverCompatibilityChangedError); + expect(readServiceInstallState()?.ownership).toBeUndefined(); + }); +}); + describe("parsing", () => { const valid = { version: 2, codexHome: "/c", opencodexHome: "/o", backend: "scheduler" }; @@ -233,6 +429,11 @@ describe("parsing", () => { expect(parseServiceInstallState({ ...valid, revision: 0 })?.revision).toBe(0); expect(parseServiceInstallState({ ...valid, revision: 1.5 })).toBeNull(); expect(parseServiceInstallState({ ...valid, revision: -1 })).toBeNull(); + expect(parseServiceInstallState({ ...valid, revision: Number.MAX_SAFE_INTEGER })).not.toBeNull(); + expect(parseServiceInstallState({ + ...valid, + ownership: { owner: "desktop", installId: "a", consentGeneration: Number.MAX_SAFE_INTEGER }, + })).not.toBeNull(); expect(parseServiceInstallState(valid)?.revision).toBeUndefined(); }); @@ -259,18 +460,23 @@ describe("the record is read fail-closed", () => { test("a corrupt anchor is unknown; corrupt legacy leftovers are ignored", () => { writeFileSync(serviceStatePath(), "not json"); expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath()])).kind).toBe("unknown"); + unlinkSync(serviceStatePath()); // The second path is the legacy default-home entry. Junk left there by an old version // must not be able to block every repair on the machine. - recordServiceOwner(DESKTOP); + grantServiceOwner(DESKTOP); const legacy = home.path("legacy-service-state.json"); writeFileSync(legacy, "{ broken"); - const resolution = resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath(), legacy])); - expect(resolution).toEqual({ kind: "owned", ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 1 } }); + const resolution = resolveServiceOwnership(inspectServiceStateEvidence([legacy, serviceStatePath()])); + expect(resolution).toEqual({ + kind: "owned", + ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 1 }, + revision: expect.any(Number), + }); }); test("paths that name different owners are unknown", () => { - recordServiceOwner(DESKTOP); + grantServiceOwner(DESKTOP); const other = home.path("other-service-state.json"); const record = JSON.parse(readFileSync(serviceStatePath(), "utf8")); writeFileSync(other, JSON.stringify({ ...record, ownership: { ...record.ownership, installId: "app-install-b" } })); @@ -278,7 +484,8 @@ describe("the record is read fail-closed", () => { }); test("absent everywhere is the only thing that means no claim", () => { - expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath()]))).toEqual({ kind: "none" }); + expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath()]))) + .toEqual({ kind: "none", revision: 0 }); }); /** @@ -297,118 +504,94 @@ describe("the record is read fail-closed", () => { describe("the generation cannot be reused", () => { test("a release keeps the high-water mark so the next grant does not repeat it", () => { - expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); - releaseServiceOwner(); + expect(grantServiceOwner(DESKTOP).ownership.consentGeneration).toBe(1); + releaseCurrentOwner(); expect(readServiceInstallState()?.consentGenerationCeiling).toBe(1); // Without the ceiling this would be 1 again, and an app-local record still holding the // first 1 would read the second grant as its own prior consent. - expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(2); + expect(grantServiceOwner(DESKTOP).ownership.consentGeneration).toBe(2); }); test("an ordinary install-state write carries the ceiling forward", () => { - recordServiceOwner(DESKTOP); - releaseServiceOwner(); + grantServiceOwner(DESKTOP); + releaseCurrentOwner(); writeServiceInstallState("scheduler", null); expect(readServiceInstallState()?.consentGenerationCeiling).toBe(1); - expect(recordServiceOwner({ owner: "desktop", installId: "app-install-b" }).consentGeneration).toBe(2); + expect(grantServiceOwner({ owner: "desktop", installId: "app-install-b" }).ownership.consentGeneration).toBe(2); }); }); describe("the anchor lock", () => { - test("a lock another process holds blocks the write rather than racing it", () => { - writeFileSync(serviceStatePath() + ".lock", ""); - expect(() => swapServiceInstallState(() => ({ - version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", - }), { lockWaitMs: 50 })).toThrow(/another process is writing/); - // Nothing was written: the swap never reached a commit. + test("a live update lease blocks ownership mutation before the state lock is touched", () => { + const leasePath = serviceStatePath() + ".mutation.lock"; + const processInstance = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const token = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + mkdirSync(leasePath, { recursive: true }); + writeFileSync(`${leasePath}/v1-777-${processInstance}-${token}.json`, JSON.stringify({ + version: 1, pid: 777, processInstance, token, createdAt: 1, + })); + expect(() => writeServiceInstallState("scheduler", null, { + paths: [serviceStatePath()], + mutationLease: { waitMs: 0, now: () => 1_000_000, processAlive: () => true }, + })).toThrow(/runtime mutation lease/); expect(existsSync(serviceStatePath())).toBe(false); }); - test("a swap nested inside another one is not a race and does not deadlock", () => { - writeServiceInstallState("scheduler", null); - const result = swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { - beforeCommit: attempt => { if (attempt === 0) recordServiceOwner(DESKTOP); }, + test("a crashed incomplete update lease is reclaimed only after the stale grace", () => { + const leasePath = serviceStatePath() + ".mutation.lock"; + mkdirSync(leasePath, { recursive: true }); + writeServiceInstallState("scheduler", null, { + paths: [serviceStatePath()], + mutationLease: { + waitMs: 0, + now: () => Date.now() + 60_000, + processAlive: () => false, + }, }); - expect(result?.ownership?.installId).toBe("app-install-a"); + expect(readServiceInstallState()?.backend).toBe("scheduler"); }); - /** - * The lock file names its holder. Without that, a holder evicted as stale would delete the - * REPLACEMENT lock on its way out and hand a third writer the pathname while the second is - * still inside its critical section. - */ - test("release removes only the lock instance this holder created", () => { + test("age never evicts a holder whose PID is still alive", () => { const lockPath = serviceStatePath() + ".lock"; - let observed = ""; - writeServiceInstallState("scheduler", null); - swapServiceInstallState(current => { - observed = readFileSync(lockPath, "utf8").trim(); - // Stand in for an eviction: the pathname now belongs to somebody else. - writeFileSync(lockPath, "a-different-holder\n"); - return { ...current! }; - }); - expect(observed).not.toBe(""); - expect(existsSync(lockPath)).toBe(true); - expect(readFileSync(lockPath, "utf8").trim()).toBe("a-different-holder"); - unlinkSync(lockPath); - }); -}); - -describe("the record is replaced as a unit", () => { - /** - * An in-place write truncates first, so an interrupted commit used to leave the anchor empty - * or half-serialized. Since the reader became fail-closed that reads as `unknown`, which - * blocks start, repair, restart and every update until the operator runs a takeover install. - */ - test("a commit leaves no staging file behind and the record stays parseable", () => { - writeServiceInstallState("scheduler", null); - recordServiceOwner(DESKTOP); - const leftovers = readdirSync(home.root).filter(name => name.endsWith(".tmp")); - expect(leftovers).toEqual([]); - expect(readServiceInstallState()?.ownership?.installId).toBe("app-install-a"); - }); - - test("the write path stages and renames rather than truncating the record in place", () => { - const source = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); - const commit = source.slice( - source.indexOf("function commitServiceStateFile("), - source.indexOf("export function swapServiceInstallState("), - ); - expect(commit).toContain("renameSync(staged, path)"); - // Hardened BEFORE the rename: between rename and chmod the record would be readable - // at the default mode. - expect(commit.indexOf("hardenSecretPath(staged")).toBeLessThan(commit.indexOf("renameSync(staged, path)")); + const processInstance = "11111111-1111-4111-8111-111111111111"; + const token = "22222222-2222-4222-8222-222222222222"; + mkdirSync(lockPath, { recursive: true }); + writeFileSync(`${lockPath}/v1-777-${processInstance}-${token}.json`, JSON.stringify({ + version: 1, pid: 777, processInstance, token, createdAt: 1, + })); + expect(() => swapServiceInstallState(() => ({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", + }), { + paths: [serviceStatePath()], + lockWaitMs: 0, + lockHooks: { now: () => 1_000_000, processAlive: () => true }, + })).toThrow(/another process owns/); + // Nothing was written: the swap never reached a commit. + expect(existsSync(serviceStatePath())).toBe(false); }); -}); -describe("a claim recorded under the lock is never overwritten by an older one", () => { - /** - * `writeServiceInstallState` used to resolve ownership BEFORE the swap took the lock. A - * takeover landing in between reached `current`, passed the revision check untouched, and - * was then overwritten by the older claim the resolution had captured — a lost update the - * compare-and-swap cannot see, because the stale value never came from the base record. - */ - test("the resolution is read inside the swap, not before it", () => { - const source = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); - const writer = source.slice( - source.indexOf("export function writeServiceInstallState("), - source.indexOf("function preservedConsent("), - ); - expect(writer).toContain("preservedConsent(current, resolveServiceOwnership())"); - expect(writer).not.toMatch(/const resolution = resolveServiceOwnership\(\);/); + test("a late release cannot remove a successor lock", () => { + const statePath = serviceStatePath(); + const lockPath = `${statePath}.lock`; + const successorInstance = "33333333-3333-4333-8333-333333333333"; + const successorToken = "44444444-4444-4444-8444-444444444444"; + swapServiceInstallState(current => ({ + ...(current ?? { version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler" }), + launcherPath: "/opt/ocx", + }), { + paths: [statePath], + lockHooks: { + beforeRelease: () => { + for (const entry of readdirSync(lockPath)) unlinkSync(`${lockPath}/${entry}`); + rmSync(lockPath, { recursive: true }); + mkdirSync(lockPath, { recursive: true }); + writeFileSync(`${lockPath}/v1-888-${successorInstance}-${successorToken}.json`, JSON.stringify({ + version: 1, pid: 888, processInstance: successorInstance, token: successorToken, createdAt: 2, + })); + }, + }, + }); + expect(existsSync(`${lockPath}/v1-888-${successorInstance}-${successorToken}.json`)).toBe(true); }); - test("a higher generation wins, and an equal generation keeps the anchor", () => { - recordServiceOwner(DESKTOP); - recordServiceOwner({ owner: "desktop", installId: "app-install-b" }); - const before = readServiceInstallState(); - expect(before?.ownership?.installId).toBe("app-install-b"); - expect(before?.ownership?.consentGeneration).toBe(2); - - // An ordinary install-state refresh must not demote it to the earlier grant. - writeServiceInstallState("scheduler", null); - const after = readServiceInstallState(); - expect(after?.ownership?.installId).toBe("app-install-b"); - expect(after?.ownership?.consentGeneration).toBe(2); - }); }); diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index d9683958760..963be923f09 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -12,12 +12,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { repoPath } from "../helpers/repo-root"; import { planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; -import { - inspectInstallStateBytes, - resolveOwnershipFromEvidence, - serviceStateFilesFor, -} from "../../src/service/install-state-contract.mjs"; -import { parseServiceOwnership } from "../../src/service/state"; +import { parseInstallStateRecord, selectAuthoritativeServiceState } from "../../src/service/install-state-contract.mjs"; describe("the runtime-ownership veto", () => { test("a desktop owner stops both the stop and the service refresh, and says so", () => { @@ -25,8 +20,9 @@ describe("the runtime-ownership veto", () => { ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 3 }, serviceInstalled: true, }); - expect(plan.stopRuntime).toBe(false); - expect(plan.refreshService).toBe(false); + expect(plan.mayReplacePackage).toBe(false); + expect(plan.mayStopRuntime).toBe(false); + expect(plan.mayRestoreService).toBe(false); expect(plan.notice).toContain("app-install-a"); expect(plan.notice).toContain("consent generation 3"); expect(plan.notice).toContain("neither re-enabled nor restarted"); @@ -35,9 +31,9 @@ describe("the runtime-ownership veto", () => { test("a CLI owner and an unowned runtime both take the ordinary path", () => { for (const ownership of [null, { owner: "cli", installId: "npm-install", consentGeneration: 1 }]) { expect(planUpdateRuntimeHandling({ ownership, serviceInstalled: true })) - .toEqual({ stopRuntime: true, refreshService: true, notice: null }); + .toEqual({ mayReplacePackage: true, mayStopRuntime: true, mayRestoreService: true, notice: null }); expect(planUpdateRuntimeHandling({ ownership, serviceInstalled: false })) - .toEqual({ stopRuntime: true, refreshService: false, notice: null }); + .toEqual({ mayReplacePackage: true, mayStopRuntime: true, mayRestoreService: false, notice: null }); } }); @@ -46,106 +42,47 @@ describe("the runtime-ownership veto", () => { ownership: { owner: "something-newer", installId: "x", consentGeneration: 1 }, serviceInstalled: true, }); - expect(plan.stopRuntime).toBe(false); + expect(plan.mayReplacePackage).toBe(false); + expect(plan.mayStopRuntime).toBe(false); }); }); -/** - * The launcher used to keep its own reader, "kept in step" by a table of claim shapes. It was - * not in step: it inspected only the anchor path, and it treated a record that fails the whole - * install-state contract as an unowned runtime whenever its `ownership` field was simply - * absent. That is permission to stop a foreign runtime and reactivate the npm service, so the - * reader is gone and both runtimes import one contract. - */ -describe("one contract, not two readers kept in step", () => { - const accepted = [ - { owner: "desktop", installId: "a", consentGeneration: 0 }, - { owner: "cli", installId: "a", consentGeneration: 12 }, - { owner: "desktop", installId: "a", consentGeneration: 1, grantedBy: "first-launch" }, - ]; - const rejected = [ - { owner: "root", installId: "a", consentGeneration: 1 }, - { owner: "desktop", installId: "", consentGeneration: 1 }, - { owner: "desktop", installId: "a" }, - { owner: "desktop", installId: "a", consentGeneration: -1 }, - { owner: "desktop", installId: "a", consentGeneration: 1.5 }, - { owner: "desktop", installId: "a", consentGeneration: "1" }, - "desktop", - null, - ]; - const record = (extra: Record): string => JSON.stringify({ - version: 2, codexHome: "/c", opencodexHome: "/o", backend: "scheduler", ...extra, - }); - const resolveText = (text: string) => resolveOwnershipFromEvidence( - [inspectInstallStateBytes("/anchor", () => text)], - ); - - test("the authoritative reader and the shared contract accept the same claims", () => { - for (const ownership of accepted) { - expect(parseServiceOwnership(ownership)).not.toBeNull(); - expect(resolveText(record({ ownership }))).toEqual({ kind: "owned", ownership }); - } +describe("the Node and Bun paths share one full-record authority", () => { + const state = (revision: number, installId = "desktop-a") => ({ + version: 2, codexHome: "/codex", opencodexHome: "/opencodex", backend: "scheduler", + revision, ownership: { owner: "desktop" as const, installId, consentGeneration: 1 }, }); - test("a rejected claim is unknown, never an unowned runtime", () => { - for (const ownership of rejected) { - expect(parseServiceOwnership(ownership)).toBeNull(); - expect(resolveText(record({ ownership })).kind).toBe("unknown"); - } + test("a complete record is required before ownership is projected", () => { + expect(parseInstallStateRecord(state(1))?.ownership?.installId).toBe("desktop-a"); + expect(parseInstallStateRecord({ ownership: state(1).ownership })).toBeNull(); }); - /** - * The case the launcher got wrong: a record that carries no `ownership` field but fails the - * contract for another reason. It answered "known unowned" and permitted the stop and the - * service refresh; the contract answers `unknown` and vetoes both. - */ - test("an ownership-free record that fails the contract is unknown, not unowned", () => { - expect(resolveText(JSON.stringify({ version: 2 })).kind).toBe("unknown"); - expect(resolveText(JSON.stringify({ version: 99, codexHome: "/c", opencodexHome: "/o" })).kind).toBe("unknown"); - expect(resolveText(record({ codexHome: "" })).kind).toBe("unknown"); - expect(resolveText("{").kind).toBe("unknown"); - expect(resolveText("[]").kind).toBe("unknown"); - // A record that satisfies the contract and simply has no claim is the one "none" case. - expect(resolveText(record({})).kind).toBe("none"); - }); - - test("a claim on the legacy path alone is still a claim, and a conflict is unknown", () => { - const anchor = inspectInstallStateBytes("/anchor", () => record({})); - const claim = { owner: "desktop", installId: "app-a", consentGeneration: 1 }; - const legacy = inspectInstallStateBytes("/legacy", () => record({ ownership: claim })); - expect(resolveOwnershipFromEvidence([anchor, legacy])).toEqual({ kind: "owned", ownership: claim }); - - const other = inspectInstallStateBytes("/legacy", () => record({ - ownership: { owner: "desktop", installId: "app-b", consentGeneration: 1 }, - })); - const claimed = inspectInstallStateBytes("/anchor", () => record({ ownership: claim })); - expect(resolveOwnershipFromEvidence([claimed, other]).kind).toBe("unknown"); - }); - - test("an unreadable path anywhere in the list is unknown", () => { - const unreadable = inspectInstallStateBytes("/legacy", () => { - const error = new Error("denied") as Error & { code?: string }; - error.code = "EACCES"; - throw error; - }); - expect(unreadable.kind).toBe("unreadable"); - const anchor = inspectInstallStateBytes("/anchor", () => record({})); - expect(resolveOwnershipFromEvidence([anchor, unreadable]).kind).toBe("unknown"); + test("the default-home authority wins over an older active-home mirror", () => { + const selected = selectAuthoritativeServiceState([ + { path: "active", kind: "valid", state: state(4, "old-owner") }, + { path: "default", kind: "valid", state: { ...state(5), ownership: undefined } }, + ]); + expect(selected).toMatchObject({ kind: "state", revision: 5, needsRepair: true }); + if (selected.kind === "state") expect(selected.state.ownership).toBeUndefined(); }); - test("an absent path is the only answer that can mean no claim", () => { - const absent = inspectInstallStateBytes("/anchor", () => { - const error = new Error("missing") as Error & { code?: string }; - error.code = "ENOENT"; - throw error; - }); - expect(absent.kind).toBe("absent"); - expect(resolveOwnershipFromEvidence([absent])).toEqual({ kind: "none" }); + test("same-or-newer mirror disagreement is unknown rather than a vote", () => { + expect(selectAuthoritativeServiceState([ + { path: "active", kind: "valid", state: state(5, "other-owner") }, + { path: "default", kind: "valid", state: state(5) }, + ])).toMatchObject({ kind: "unknown" }); }); - test("both runtimes consult the same path list", () => { - expect(serviceStateFilesFor("/home/.opencodex", "/home/.opencodex")).toHaveLength(1); - expect(serviceStateFilesFor("/pinned", "/home/.opencodex")).toHaveLength(2); + test("an absent authority imports one valid legacy record, while unreadable authority refuses", () => { + expect(selectAuthoritativeServiceState([ + { path: "active", kind: "valid", state: state(3) }, + { path: "default", kind: "absent" }, + ])).toMatchObject({ kind: "state", revision: 3, needsRepair: true }); + expect(selectAuthoritativeServiceState([ + { path: "active", kind: "valid", state: state(3) }, + { path: "default", kind: "unreadable", reason: "EACCES" }, + ])).toMatchObject({ kind: "unknown" }); }); }); @@ -155,19 +92,18 @@ describe("both updaters consult the shared rule", () => { test("the Bun updater gates its stop, its refresh and its restart hint", () => { expect(bunPath).toContain("from \"./runtime-ownership.mjs\""); - expect(bunPath).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); - expect(bunPath).toContain("if (runtimePlan.refreshService) {"); - expect(bunPath).toContain("} else if (runtimePlan.stopRuntime) {"); - expect(bunPath).toContain("if (stopAttempted && runtimePlan.refreshService && postUpdateLauncherUsable)"); + expect(bunPath).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(bunPath).toContain("if (!runtimePlan.mayReplacePackage)"); + expect(bunPath).toContain("if (postInstallPlan.mayRestoreService) {"); }); test("the npm launcher gates its stop, its refresh and its failure recovery", () => { expect(launcher).toContain("from \"../src/update/runtime-ownership.mjs\""); - expect(launcher).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); - expect(launcher).toContain("if (runtimePlan.refreshService) {"); + expect(launcher).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(launcher).toContain("if (!runtimePlan.mayReplacePackage)"); // Nothing was stopped, so nothing is recovered: starting a proxy here would put a // second one beside the runtime the app is managing. - expect(launcher).toContain("if (!runtimePlan.stopRuntime) return;"); + expect(launcher).toContain("if (!recoveryPlan.mayStopRuntime) return;"); }); test("neither updater reimplements the decision", () => { @@ -184,7 +120,7 @@ describe("both updaters consult the shared rule", () => { */ test("the launcher reads the record only through the shared contract", () => { expect(launcher).toContain('from "../src/service/install-state-contract.mjs"'); - expect(launcher).toContain("resolveOwnershipFromEvidence(evidence)"); + expect(launcher).toContain("selectAuthoritativeServiceState("); expect(launcher).toContain("serviceStateFilesFor("); expect(launcher).not.toContain("parsed.ownership"); expect(launcher).not.toContain("consentGeneration"); @@ -192,15 +128,16 @@ describe("both updaters consult the shared rule", () => { const state = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); expect(state).toContain('from "./install-state-contract.mjs"'); expect(state).toContain("return parseInstallStateRecord(value)"); - expect(state).toContain("return resolveOwnershipFromEvidence(evidence)"); + expect(state).toContain("selectAuthoritativeServiceState("); }); }); describe("an unreadable record is not an unowned runtime", () => { test("unknown ownership vetoes both halves and points at the way back", () => { const plan = planUpdateRuntimeHandling({ ownership: null, ownershipUnknown: true, serviceInstalled: true }); - expect(plan.stopRuntime).toBe(false); - expect(plan.refreshService).toBe(false); + expect(plan.mayReplacePackage).toBe(false); + expect(plan.mayStopRuntime).toBe(false); + expect(plan.mayRestoreService).toBe(false); expect(plan.notice).toContain("could not be determined"); expect(plan.notice).toContain("ocx service install"); }); @@ -230,7 +167,7 @@ describe("every updater re-reads ownership before it starts a proxy directly", ( expect(fallbackAt).toBeGreaterThan(-1); const recheckAt = source.lastIndexOf("planUpdateRuntimeHandling({", fallbackAt); expect(recheckAt).toBeGreaterThan(-1); - expect(source.slice(recheckAt, fallbackAt)).toContain("nowOwned.stopRuntime"); + expect(source.slice(recheckAt, fallbackAt)).toContain("nowOwned.mayStopRuntime"); } }); @@ -242,14 +179,15 @@ describe("every updater re-reads ownership before it starts a proxy directly", ( test("the dashboard worker checks before it restarts anything", () => { const restartAt = worker.indexOf("if (restart) {"); const handoffAt = worker.indexOf("finishGuiUpdateRestart(", restartAt); - const gateAt = worker.indexOf("updateRestartVeto(", restartAt); + const gateAt = worker.indexOf("runUpdateRestartWithOwnershipLease(", restartAt); expect(gateAt).toBeGreaterThan(restartAt); expect(gateAt).toBeLessThan(handoffAt); - expect(worker.slice(gateAt, handoffAt)).toContain("if (veto)"); - expect(worker.slice(gateAt, handoffAt)).toContain("restarted: false"); + expect(worker.slice(gateAt)).toContain("outcome.kind === \"veto\""); + expect(worker.slice(gateAt)).toContain("restarted: false"); // The veto is the shared rule, not a second opinion about ownership. const veto = readFileSync(repoPath("src", "update", "restart-ownership.ts"), "utf8"); expect(veto).toContain("planUpdateRuntimeHandling({"); expect(veto).toContain("resolveServiceOwnership"); + expect(veto).toContain("acquireOwnershipMutationLease"); }); }); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 010bab6cc63..8b6d0777970 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -536,7 +536,7 @@ describe("update stops the running proxy before replacing files", () => { expect(stopAt).toBeLessThan(updateAt); // The four signals are now inside a runtime-ownership veto: a desktop-owned runtime is // not stopped at all. Every original reason to stop still reaches the gate unchanged. - expect(updateSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(updateSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); }); test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => { @@ -602,12 +602,9 @@ describe("update stops the running proxy before replacing files", () => { expect(updateSource).toContain("serviceReinstallArgs()"); expect(launcherSource).toContain("aborting the update"); expect(launcherSource).toContain('"service", "repair"'); - // The launcher still reads the install-state record for service-installed detection, and - // for the backend choice on the genuinely-absent install fallback. It no longer spells the - // file name: the path list moved into the contract both runtimes import, so the launcher - // cannot consult a shorter list than the authoritative reader. - expect(launcherSource).toContain("serviceStateFilesFor(configDir()"); - expect(launcherSource).toContain("const serviceWasInstalled = existsSync(serviceStatePath)"); + // The launcher reads the shared active/default state-path set for service-installed + // detection and the authoritative backend on the genuinely-absent install fallback. + expect(launcherSource).toContain("serviceStateFilesFor"); // That marker can be STALE, so the fallback asks for structured state rather than // parsing a failure message; bin/ocx.mjs is plain Node and cannot import // diagnoseService(), so it reads startup.serviceInstalled from `status --json`. @@ -788,8 +785,8 @@ esac // A pending-teardown receipt is a fourth reason to stop: after a parent crashed // mid-deferral the service, pid and runtime records can all be absent while shared // client config still points at a proxy that is gone (#3008). - expect(updateSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); - expect(launcherSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(updateSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(launcherSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); // The rule now lives in the shared post-stop decision both lanes import (#3008): a // history-only stop proceeds, every other nonzero status and any surviving runtime // state aborts. Pinned by tests/update/update-stop-classification.test.ts. From 28d88abf850c3ceea479c7c2dabd900f6a26f9f0 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:38:10 +0900 Subject: [PATCH 81/87] fix(desktop): keep one local bundle format from destroying the rest (#5410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, bun run build:local asked for appimage,deb in ONE tauri invocation. When AppImage bundling failed (linuxdeploy missing a host dependency), the invocation died and the deb was never attempted — a contributor following the README got zero artifacts and an error that named a tool they never invoked (observed on a real GNOME desktop, devlog plan 260921 / 120_install_verification.md). Each format now builds in its own invocation, every format is attempted, and the summary reports each outcome beside the artifacts that did build; the exit code is non-zero when any requested format failed. A failing format is retried once with --verbose: at the bundler's default log level the error is a bare "failed to run linuxdeploy" with the tool's stderr discarded, and the verbose pass is the branch where those diagnostics reach the terminal. The release workflow builds its artifacts on its own runner image and is untouched. --- desktop/README.md | 7 +- desktop/scripts/build-local.ts | 145 ++++++++++++++---- .../release-desktop-scripts.test.ts | 127 +++++++++++++++ 3 files changed, 245 insertions(+), 34 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 153293a55da..9e7fcc0eda5 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -39,7 +39,12 @@ bun run build:local This asks for the host platform's installable bundles only (app and dmg on macOS, msi and nsis setup exe on Windows, AppImage and deb on Linux), so no updater archive is produced and none is -expected to be signed. It prints the bundle paths and exits zero. The release path below is unchanged: a published +expected to be signed. Each format is attempted in its own invocation: a format this machine +cannot bundle (for example an AppImage when a linuxdeploy dependency is missing) fails on its own +line without destroying the formats that do build, the failing format is retried once with +`--verbose` so the bundler's own diagnostics are visible, and the summary prints every format's +outcome beside the artifacts that were produced. The exit code is non-zero if any format failed. +The release path below is unchanged: a published updater artifact still has to be signed. ## Release packaging and updates diff --git a/desktop/scripts/build-local.ts b/desktop/scripts/build-local.ts index 2616c7fc509..634c92ffbce 100644 --- a/desktop/scripts/build-local.ts +++ b/desktop/scripts/build-local.ts @@ -19,9 +19,22 @@ * there is nothing to sign and nothing is skipped unsigned. Selecting bundle targets is not enough: * `createUpdaterArtifacts` is a config flag, so `--bundles app,dmg` still produces * `OpenCodex.app.tar.gz (updater)` and still fails. The override has to reach the config itself. + * + * Two more local-only behaviours, learned from a real GNOME desktop (devlog plan 260921, + * 120_install_verification.md): + * + * - Formats build in SEPARATE invocations. A single `--bundles appimage,deb` call dies on the + * first failing format, so a host that cannot bundle an AppImage (a missing linuxdeploy + * dependency) also lost the deb it could have built. Each format is attempted, and the + * summary at the end names every format's outcome; the exit code is non-zero if any of + * them failed, and the artifacts that DID build are printed either way. + * - A failing format is retried once with `--verbose`. At the bundler's default log level + * the error is a bare "failed to run linuxdeploy" with the tool's own diagnostics + * discarded; the verbose pass is the branch where that stderr actually reaches the + * terminal, so the failure says WHY instead of naming a tool nobody invoked. */ import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, readdirSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -34,12 +47,6 @@ const LOCAL_BUNDLES: Record = { linux: ["appimage", "deb"], }; -const bundles = LOCAL_BUNDLES[process.platform]; -if (!bundles) { - console.error(`[build:local] unsupported host platform: ${process.platform}`); - process.exit(1); -} - /** * Config merged over `tauri.conf.json` for this invocation only. * @@ -49,34 +56,106 @@ if (!bundles) { */ const LOCAL_CONFIG = JSON.stringify({ bundle: { createUpdaterArtifacts: false } }); -function run(): number { - const extra = process.argv.slice(2); - const args = [ - "tauri", "build", "--ci", - "--bundles", bundles.join(","), - "--config", LOCAL_CONFIG, - ...extra, - ]; - const result = spawnSync("bunx", args, { cwd: desktopDir, stdio: "inherit" }); - if (result.error) { - console.error(`[build:local] could not start tauri: ${result.error.message}`); - return 1; - } - return result.status ?? 1; +export interface SpawnResult { + status: number | null; + error?: Error; } -const status = run(); -if (status === 0) { - const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); - // Naming what exists is the point of the script: the previous output ended on an error line, so - // the artifacts it had already written were the least visible thing in it. - for (const dir of ["macos", "dmg", "msi", "nsis", "appimage", "deb"]) { - const directory = join(bundleRoot, dir); - if (!existsSync(directory)) continue; - for (const name of readdirSync(directory)) { - if (/\.(app|dmg|msi|exe|AppImage|deb)$/i.test(name)) console.log(`[build:local] ${join(directory, name)}`); +export interface ArtifactEntry { + path: string; + mtimeMs: number; +} + +export interface BuildLocalDeps { + spawn(args: string[]): SpawnResult; + log(line: string): void; + error(line: string): void; + listArtifacts(): ArtifactEntry[]; + argv: string[]; + platform: string; +} + +export interface FormatAttempt { + format: string; + status: number; +} + +export function summarizeAttempts(attempts: FormatAttempt[]): { exitCode: number; lines: string[] } { + const lines = attempts.map( + attempt => `[build:local] ${attempt.format}: ${attempt.status === 0 ? "ok" : `FAILED (exit ${attempt.status})`}`, + ); + return { exitCode: attempts.every(attempt => attempt.status === 0) ? 0 : 1, lines }; +} + +export function runBuildLocal(deps: BuildLocalDeps): number { + const bundles = LOCAL_BUNDLES[deps.platform]; + if (!bundles) { + deps.error(`[build:local] unsupported host platform: ${deps.platform}`); + return 1; + } + // Snapshot before building: a bundle directory that already holds last week's AppImage + // must not be reported as this run's output when this run's AppImage attempt fails. + const baseline = new Map(deps.listArtifacts().map(entry => [entry.path, entry.mtimeMs])); + const attempts: FormatAttempt[] = []; + for (const format of bundles) { + // One invocation per format: a format this host cannot build must not destroy the + // artifacts of formats it can. + const args = ["tauri", "build", "--ci", "--bundles", format, "--config", LOCAL_CONFIG, ...deps.argv]; + const first = deps.spawn(args); + let status = first.status ?? 1; + if (first.error) { + deps.error(`[build:local] could not start tauri: ${first.error.message}`); + status = 1; + } else if (status !== 0) { + // The bundler reports a bare "failed to run " at its default log level; the + // verbose pass is where the tool's own stderr reaches the terminal. The retry is + // diagnostics only — the recorded status stands either way. + deps.error(`[build:local] ${format} failed; rerunning with --verbose for the bundler's diagnostics`); + const retry = deps.spawn(["tauri", "--verbose", "build", "--ci", "--bundles", format, "--config", LOCAL_CONFIG, ...deps.argv]); + if (retry.error) deps.error(`[build:local] could not start tauri: ${retry.error.message}`); } + attempts.push({ format, status }); } - console.log("[build:local] updater artifacts skipped; release signing is unchanged."); + // Name what THIS run produced even when something failed: an error line at the end is + // the least visible place for artifacts that already built. + const produced = deps.listArtifacts().filter( + entry => !baseline.has(entry.path) || baseline.get(entry.path) !== entry.mtimeMs, + ); + for (const entry of produced) deps.log(`[build:local] ${entry.path}`); + const summary = summarizeAttempts(attempts); + for (const line of summary.lines) deps.log(line); + if (summary.exitCode === 0) { + deps.log("[build:local] updater artifacts skipped; release signing is unchanged."); + } + return summary.exitCode; +} + +function main(): void { + const status = runBuildLocal({ + spawn: args => spawnSync("bunx", args, { cwd: desktopDir, stdio: "inherit" }), + log: line => console.log(line), + error: line => console.error(line), + listArtifacts: () => { + const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); + const artifacts: ArtifactEntry[] = []; + for (const dir of ["macos", "dmg", "msi", "nsis", "appimage", "deb"]) { + const directory = join(bundleRoot, dir); + if (!existsSync(directory)) continue; + for (const name of readdirSync(directory)) { + if (/\.(app|dmg|msi|exe|AppImage|deb)$/i.test(name)) { + const full = join(directory, name); + artifacts.push({ path: full, mtimeMs: statSync(full).mtimeMs }); + } + } + } + return artifacts; + }, + argv: process.argv.slice(2), + platform: process.platform, + }); + process.exit(status); +} + +if (import.meta.main) { + main(); } -process.exit(status); diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 80a2a814cc8..52ed2d90919 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -4,6 +4,12 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; +import { + runBuildLocal, + summarizeAttempts, + type ArtifactEntry, + type BuildLocalDeps, +} from "../../desktop/scripts/build-local"; import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; import { standaloneTargets } from "../../scripts/standalone-targets"; import { @@ -229,6 +235,127 @@ describe("desktop release scripts", () => { * an extension signed that way, so the app would have installed with no widget and nothing in * the build would have said so. */ +describe("local bundle builds", () => { + type Script = Record; + const depsFor = ( + scripted: Script, + opts: { platform?: string; initialArtifacts?: ArtifactEntry[]; argv?: string[] } = {}, + ) => { + const calls: string[][] = []; + const logs: string[] = []; + const errors: string[] = []; + const artifacts = (opts.initialArtifacts ?? []).map(entry => ({ ...entry })); + const deps: BuildLocalDeps = { + spawn: args => { + calls.push(args); + const format = args[args.indexOf("--bundles") + 1]!; + const verbose = args.includes("--verbose"); + const key = verbose ? `${format}#v` : format; + const status = Object.hasOwn(scripted, key) ? scripted[key]! : 0; + // A successful non-verbose build refreshes the artifact, like the real bundler. + if (status === 0 && !verbose) { + const existing = artifacts.find(entry => entry.path.includes(format)); + if (existing) existing.mtimeMs += 1; + else artifacts.push({ path: `/out/OpenCodex-test_${format}`, mtimeMs: 200 }); + } + return { status }; + }, + log: line => { logs.push(line); }, + error: line => { errors.push(line); }, + listArtifacts: () => artifacts.map(entry => ({ ...entry })), + argv: opts.argv ?? [], + platform: opts.platform ?? "linux", + }; + return { calls, logs, errors, deps }; + }; + + test("a failing format does not destroy the formats that build", () => { + // Observed on a real GNOME desktop (120_install_verification.md): one shared + // invocation died on the AppImage and the deb was never attempted. + const { calls, logs, deps } = depsFor({ appimage: 1, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + const formats = calls.map(args => args[args.indexOf("--bundles") + 1]); + expect(formats).toContain("appimage"); + expect(formats).toContain("deb"); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + expect(logs.some(line => line.includes("deb: ok"))).toBe(true); + expect(logs.some(line => line.includes("/out/OpenCodex-test_deb"))).toBe(true); + expect(logs.some(line => line.includes("updater artifacts skipped"))).toBe(false); + }); + + test("a failing format is retried verbosely so the bundler's own stderr surfaces", () => { + const { calls, errors, deps } = depsFor({ appimage: 1, deb: 0 }); + runBuildLocal(deps); + expect(errors.some(line => line.includes("rerunning with --verbose"))).toBe(true); + const verboseCalls = calls.filter(args => args.includes("--verbose")); + expect(verboseCalls).toHaveLength(1); + expect(verboseCalls[0]?.slice(0, 3)).toEqual(["tauri", "--verbose", "build"]); + expect(verboseCalls[0]).toContain("appimage"); + expect(verboseCalls.some(args => args.includes("deb"))).toBe(false); + }); + + test("a verbose retry that succeeds does not change the recorded failure", () => { + const { logs, deps } = depsFor({ appimage: 1, "appimage#v": 0, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + }); + + test("stale bundle output is not reported as this run's artifact", () => { + const { logs, deps } = depsFor( + { appimage: 1, deb: 0 }, + { initialArtifacts: [{ path: "/out/OpenCodex-test_appimage", mtimeMs: 100 }] }, + ); + runBuildLocal(deps); + expect(logs.some(line => line.includes("/out/OpenCodex-test_appimage"))).toBe(false); + expect(logs.some(line => line.includes("/out/OpenCodex-test_deb"))).toBe(true); + }); + + test("a spawn that never started counts as a failure", () => { + const { logs, deps } = depsFor({ appimage: null, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + }); + + test("a spawn error reports the launch failure", () => { + const errors: string[] = []; + const deps = depsFor({ deb: 0 }).deps; + const originalSpawn = deps.spawn; + deps.error = line => { errors.push(line); }; + deps.spawn = args => (args.includes("appimage") ? { status: null, error: new Error("spawn bunx ENOENT") } : originalSpawn(args)); + expect(runBuildLocal(deps)).toBe(1); + expect(errors.some(line => line.includes("could not start tauri"))).toBe(true); + }); + + test("the invocation shape is one tauri build per format, extra argv forwarded everywhere", () => { + const { calls, deps } = depsFor({ appimage: 1, deb: 0 }, { argv: ["--target", "x86_64-unknown-linux-gnu"] }); + runBuildLocal(deps); + expect(calls[0]?.slice(0, 5)).toEqual(["tauri", "build", "--ci", "--bundles", "appimage"]); + expect(calls[1]?.slice(0, 5)).toEqual(["tauri", "--verbose", "build", "--ci", "--bundles"]); + for (const call of calls) { + expect(call.slice(-2)).toEqual(["--target", "x86_64-unknown-linux-gnu"]); + } + }); + + test("a fully successful build exits zero and keeps the updater note", () => { + const { logs, deps } = depsFor({}); + expect(runBuildLocal(deps)).toBe(0); + expect(logs.some(line => line.includes("updater artifacts skipped"))).toBe(true); + }); + + test("macOS hosts build app and dmg", () => { + const { calls, deps } = depsFor({}, { platform: "darwin" }); + expect(runBuildLocal(deps)).toBe(0); + const formats = calls.map(args => args[args.indexOf("--bundles") + 1]); + expect(formats).toEqual(["app", "dmg"]); + }); + + test("summarizeAttempts decides the exit code from the per-format outcomes", () => { + expect(summarizeAttempts([{ format: "appimage", status: 0 }, { format: "deb", status: 0 }]).exitCode).toBe(0); + expect(summarizeAttempts([{ format: "appimage", status: 1 }, { format: "deb", status: 0 }]).exitCode).toBe(1); + expect(summarizeAttempts([{ format: "appimage", status: 1 }, { format: "deb", status: 1 }]).lines[0]).toContain("FAILED"); + }); +}); + describe("the desktop build toolchain carries the bundle-type marker", () => { // updater.rs selects the deb updater target from tauri_utils::platform::bundle_type(), // which reads a marker the tauri-bundler patches into the binary at packaging time. From f19b229e837fe23851c097032b8c84a41e481330 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Mon, 21 Sep 2026 15:39:44 +0900 Subject: [PATCH 82/87] fix(cli): warn only for proven stale app servers (#5394) Co-authored-by: Ingwannu --- src/codex/app-server-processes.ts | 112 +++++++++++------- ...DR-0097-post-write-app-server-freshness.md | 12 ++ structure/runtime.md | 8 ++ .../codex-app-server-processes.test.ts | 51 ++++++++ 4 files changed, 143 insertions(+), 40 deletions(-) create mode 100644 structure/decisions/ADR-0097-post-write-app-server-freshness.md diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 355beaa9e50..a33a1091fd6 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -786,6 +786,58 @@ function catalogStatusFromProcesses( return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs }; } +interface ComputedCodexAppServerCatalogStatus { + status: CodexAppServerCatalogStatus; + processes: CodexAppServerProcess[]; +} + +/** + * Compute one catalog-state observation while retaining the command lines from the + * same process enumeration. The public collector deliberately exposes only the + * identity and timestamp projection; post-write warning code also needs the matched + * process records, and re-enumerating there creates a race between classification and + * reporting (as well as a second expensive Windows CIM walk). + */ +function computeCodexAppServerCatalogStatus( + io: CodexAppServerProcessIo, +): ComputedCodexAppServerCatalogStatus { + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + let snapshots: ProcessSnapshot[]; + let enumerationFailed = false; + const enumerate = io.listSnapshots ?? (() => defaultListSnapshots(platform, getuid)); + try { + snapshots = enumerate(); + } catch { + // A failed process read is unknown, never proof that nothing is running. + snapshots = []; + enumerationFailed = true; + } + const processes = codexAppServerProcessesFromSnapshots(snapshots); + if (processes.length === 0) { + return { + processes, + status: enumerationFailed + ? { state: "unknown", processes: [], catalogMtimeMs: null } + : { state: "not_running", processes: [], catalogMtimeMs: null }, + }; + } + const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); + const starts = io.readStartMs + ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const)) + : readProcessStartMsBatch(processes.map(proc => proc.pid), platform); + return { + processes, + status: catalogStatusFromProcesses(processes, catalogMtimeMs, starts), + }; +} + // Short TTL: process listing + stat run once per window even under per-turn // guidance calls (#857). let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null; @@ -889,41 +941,7 @@ export function collectCodexAppServerCatalogState( && now - catalogStateCache.atMs < catalogStateTtlMs(catalogStateCache.status.state)) { return catalogStateCache.status; } - const compute = (): CodexAppServerCatalogStatus => { - const platform = io.platform ?? process.platform; - const getuid = io.getuid ?? (() => { - try { - return typeof process.getuid === "function" ? process.getuid() : undefined; - } catch { - return undefined; - } - }); - let snapshots: ProcessSnapshot[]; - let enumerationFailed = false; - const enumerate = io.listSnapshots ?? (() => defaultListSnapshots(platform, getuid)); - try { - snapshots = enumerate(); - } catch { - // Enumeration failure must never read as "nothing running" — that would let - // positive model guidance through on guesswork (#857). The injected seam gets - // the same contract as the default path: whoever enumerates, a failure to read - // the process list is unknown, not an empty machine. - snapshots = []; - enumerationFailed = true; - } - const processes = codexAppServerProcessesFromSnapshots(snapshots); - if (processes.length === 0) { - return enumerationFailed - ? { state: "unknown", processes: [], catalogMtimeMs: null } - : { state: "not_running", processes: [], catalogMtimeMs: null }; - } - const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); - const starts = io.readStartMs - ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const)) - : readProcessStartMsBatch(processes.map(proc => proc.pid), platform); - return catalogStatusFromProcesses(processes, catalogMtimeMs, starts); - }; - const status = compute(); + const status = computeCodexAppServerCatalogStatus(io).status; if (fullyDefault) { catalogStateCache = { atMs: now, status }; } @@ -1228,16 +1246,30 @@ export function afterCatalogWriteHandleAppServers( options: AfterCatalogWriteAppServerOptions, ): AfterCatalogWriteAppServerResult { const excluded = new Set(options.excludePids ?? []); + const hint = STALE_CODEX_APP_SERVER_HINT; + if (!options.restart) { + // A running process is not necessarily stale. Classify the exact process + // enumeration that supplies the warning, and stay quiet when freshness cannot be + // established rather than presenting an unknown observation as a known mismatch. + const observed = computeCodexAppServerCatalogStatus(options.io ?? {}); + const starts = new Map(observed.status.processes.map(process => [process.pid, process.startedAtMs])); + const catalogMtimeMs = observed.status.catalogMtimeMs; + const processes = observed.processes.filter(process => !excluded.has(process.pid)); + const staleProcesses = catalogMtimeMs === null + ? [] + : processes.filter(process => { + const startedAtMs = starts.get(process.pid); + return startedAtMs !== null && startedAtMs !== undefined && startedAtMs <= catalogMtimeMs; + }); + if (staleProcesses.length === 0) return { processes, warned: false, hint }; + options.log?.error(formatStaleCodexAppServerWarning(staleProcesses)); + return { processes: staleProcesses, warned: true, hint }; + } const processes = listCodexAppServerProcesses(options.io) .filter(process => !excluded.has(process.pid)); - const hint = STALE_CODEX_APP_SERVER_HINT; if (processes.length === 0) { return { processes, warned: false, hint }; } - if (!options.restart) { - options.log?.error(formatStaleCodexAppServerWarning(processes)); - return { processes, warned: true, hint }; - } options.log?.log( `Stopping Codex app-server process(es): ${processes.map(process => process.pid).join(", ")} ` + "(active turns may be interrupted).", diff --git a/structure/decisions/ADR-0097-post-write-app-server-freshness.md b/structure/decisions/ADR-0097-post-write-app-server-freshness.md new file mode 100644 index 00000000000..070709ea12e --- /dev/null +++ b/structure/decisions/ADR-0097-post-write-app-server-freshness.md @@ -0,0 +1,12 @@ +# ADR-0097 — decision recorded under "CLI Codex restart scope" + +- Contract owner: [runtime.md](../runtime.md#cli-codex-restart-scope) + +## Decision record + +- 목적과 의도: Avoid telling an operator to interrupt a fresh Codex session after a successful sync. +- 기존 구현 및 제약 조건: Startup already classified catalog freshness, but the ordinary post-write CLI warning treated every running app-server as stale; explicit restart flags must keep their existing consent semantics. +- 검토한 주요 대안: Warn on every running process; suppress every warning; classify only the advisory non-restart path from one process observation. +- 선택한 방식: Retain command lines from the classifier's enumeration and warn only for the proven-stale subset. +- 다른 대안 대신 이 방식을 선택한 이유: Presence alone cannot prove stale state, while suppressing every warning would hide a real in-memory catalog mismatch. +- 장점, 단점 및 영향: Mixed fresh/stale sets name only stale PIDs and unknown observations stay quiet; explicit restart requests remain unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index e975d9129ea..25094f7e101 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -64,6 +64,14 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output `ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope in its summary and `--yes` description; `src/cli/system-command.ts` explains the desktop interruption when confirmation is missing and sends no restart request. Human output says the restart was requested, while `--json` preserves the complete server result, including skipped or refused desktop outcomes. +After a CLI catalog/cache write, advisory restart guidance compares each running Codex app-server's +start time with the written catalog mtime. It reports only processes proven stale; a fresh or +unreadable observation does not claim that another restart is required. Explicit +`--restart-codex` and `--restart-app-server-only` retain their operator-consent semantics and act on +verified matching processes regardless of the advisory freshness result. + +> Decision record: [ADR-0097](decisions/ADR-0097-post-write-app-server-freshness.md) + ## Hub management dashboard address When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index 343d193cea0..8f170d2f1ea 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -677,6 +677,8 @@ describe("Codex app-server process matching (#476)", () => { const snapshots = [{ pid: 7, commandLine: "codex app-server --listen unix://x" }]; const io = { listSnapshots: () => snapshots, + readStartMs: () => 1_000, + catalogMtimeMs: () => 2_000, kill: () => {}, isAlive: () => false, waitExit: () => true, @@ -700,6 +702,55 @@ describe("Codex app-server process matching (#476)", () => { expect(restarted.restart?.stopped).toEqual([7]); expect(logs.some(line => line.includes("Stopping Codex app-server"))).toBe(true); }); + + test("afterCatalogWriteHandleAppServers warns only for processes proven stale", () => { + const errors: string[] = []; + const snapshots = [ + { pid: 7, commandLine: "codex app-server --listen unix://stale" }, + { pid: 8, commandLine: "codex app-server --listen unix://fresh" }, + ]; + const result = afterCatalogWriteHandleAppServers({ + restart: false, + log: { log: () => {}, error: line => errors.push(String(line)) }, + io: { + listSnapshots: () => snapshots, + readStartMs: pid => pid === 7 ? 1_000 : 3_000, + catalogMtimeMs: () => 2_000, + }, + }); + expect(result.warned).toBe(true); + expect(result.processes.map(process => process.pid)).toEqual([7]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("PID: 7"); + expect(errors[0]).not.toContain("8"); + }); + + test("afterCatalogWriteHandleAppServers stays quiet for fresh and unknown observations", () => { + const errors: string[] = []; + const base = { + restart: false, + log: { log: () => {}, error: line => errors.push(String(line)) }, + } as const; + const fresh = afterCatalogWriteHandleAppServers({ + ...base, + io: { + listSnapshots: () => [{ pid: 7, commandLine: "codex app-server --listen unix://fresh" }], + readStartMs: () => 3_000, + catalogMtimeMs: () => 2_000, + }, + }); + const unknown = afterCatalogWriteHandleAppServers({ + ...base, + io: { + listSnapshots: () => [{ pid: 8, commandLine: "codex app-server --listen unix://unknown" }], + readStartMs: () => null, + catalogMtimeMs: () => 2_000, + }, + }); + expect(fresh.warned).toBe(false); + expect(unknown.warned).toBe(false); + expect(errors).toEqual([]); + }); }); describe("CLI /api sync wiring for stale app-servers (#476)", () => { From ac3df52cca3b960a8653157fd63d9bdd200874e3 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Mon, 21 Sep 2026 15:39:53 +0900 Subject: [PATCH 83/87] fix(responses): repair exec_command input wrappers (#5395) Co-authored-by: Ingwannu --- src/responses/function-call-compat.ts | 39 ++++++- ...-schema-bound-exec-command-input-repair.md | 12 +++ structure/transports/responses.md | 10 ++ .../responses-function-tool-repair.test.ts | 101 ++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts index c4888cf1f7a..fc26675dc16 100644 --- a/src/responses/function-call-compat.ts +++ b/src/responses/function-call-compat.ts @@ -33,6 +33,38 @@ function namespaceOf(value: unknown): string | undefined { return typeof value === "string" && value !== "functions" ? value : undefined; } +/** + * Repair one provider spelling drift for Codex's flat shell bridge. + * + * This is schema-bound rather than a general alias: `input` is also a legitimate + * argument name for arbitrary caller and MCP tools. Only the exact bare + * `exec_command` declaration can establish that its sole string input has one faithful + * reading as the required string `cmd` member. + */ +function repairExecCommandInput( + argumentsText: string, + schema: FunctionCallRepairSchema, +): string { + if (schema.namespace !== undefined || schema.name !== "exec_command") return argumentsText; + const parameters = schema.parameters; + if (!isObject(parameters) || parameters.type !== "object" + || !Array.isArray(parameters.required) || !parameters.required.includes("cmd") + || !isObject(parameters.properties) || !isObject(parameters.properties.cmd) + || parameters.properties.cmd.type !== "string") return argumentsText; + let parsed: unknown; + try { + parsed = JSON.parse(argumentsText); + } catch { + return argumentsText; + } + if (!isObject(parsed)) return argumentsText; + const keys = Object.keys(parsed); + if (keys.length !== 1 || keys[0] !== "input" || typeof parsed.input !== "string") { + return argumentsText; + } + return JSON.stringify({ cmd: parsed.input }); +} + function selectorAllows( selector: unknown, lowered: unknown, @@ -131,7 +163,12 @@ function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed if (unsafe) return item; } catch { return item; } } - const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name); + const integerRepaired = coerceIntegerToolArguments( + raw || "{}", + schema.parameters, + schema.namespace ? undefined : schema.name, + ); + const argumentsText = repairExecCommandInput(integerRepaired, schema); return argumentsText === raw ? item : { ...item, arguments: argumentsText }; } diff --git a/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md b/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md new file mode 100644 index 00000000000..8640b675e42 --- /dev/null +++ b/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md @@ -0,0 +1,12 @@ +# ADR-0098 — decision recorded under "Schema-bound flat shell repair" + +- Contract owner: [transports/responses.md](../transports/responses.md#schema-bound-flat-shell-repair) + +## Decision record + +- 목적과 의도: Let an unambiguous routed `exec_command` wrapper reach the Codex shell bridge without weakening tool identity or argument validation. +- 기존 구현 및 제약 조건: Freeform code-mode tools already unwrap `input`, but a genuinely declared flat function must keep its name and Codex validates it against required `cmd`. +- 검토한 주요 대안: Rename the call to code-mode `exec`; rewrite every function's `input`; add a schema-bound repair at authoritative function-call completion. +- 선택한 방식: Repair only the exact bare declaration and exact one-member string payload when the current-turn schema requires string `cmd`. +- 다른 대안 대신 이 방식을 선택한 이유: Tool names and arbitrary `input` fields are caller-owned; the original schema is the only authority that makes the representation change deterministic. +- 장점, 단점 및 영향: Buffered, streamed-completion, and replay paths converge on valid `cmd` arguments; previews and ambiguous or namespaced shapes stay untouched. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b1e28958fa1..a82e8f8cb08 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -125,6 +125,16 @@ Function-call wrappers around freeform bodies are restored by is recoverable because the wrapper is otherwise unusable; two alternate fields are ambiguous and therefore remain untouched. Foreign freeform grammars never receive that compatibility rewrite. +#### Schema-bound flat shell repair + +Completed Responses function calls have one separate schema-bound flat-shell repair. When the +exact bare `exec_command` declaration requires a string `cmd`, a provider result containing only +the string member `{ "input": "..." }` is rewritten to `{ "cmd": "..." }`; the call name is not +changed. Namespaced tools, additional or conflicting members, non-string values, malformed JSON, +partial streaming previews, and schemas that do not prove this exact contract remain byte-exact. + +> Decision record: [ADR-0098](../decisions/ADR-0098-schema-bound-exec-command-input-repair.md) + Progressive preview for those wrappers is decoded by `src/responses/progressive-freeform-input.ts` in both the adapter-event bridge and routed function-call restoration, over the classification in `src/responses/freeform-wrapper-scan.ts`. diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts index 599e9cd9fa9..7577ac1559e 100644 --- a/tests/responses/responses-function-tool-repair.test.ts +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -211,6 +211,77 @@ describe("pure function completion repair", () => { expect(repairFunctionCalls(missing, schemas).value).toBe(missing); }); + test("repairs the exact flat exec_command input wrapper into its required cmd", () => { + const shell = { + type: "function", + name: "exec_command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + additionalProperties: false, + }, + }; + const map = collectFunctionCallRepairSchemas({ tools: [shell] }); + const call = item('{"input":"printf hello"}', { name: "exec_command" }); + expect(repairFunctionCalls(call, map).value) + .toEqual({ ...call, arguments: '{"cmd":"printf hello"}' }); + + const reserved = item('{"input":"printf hello"}', { + name: "exec_command", + namespace: "functions", + }); + expect(repairFunctionCalls(reserved, map).value) + .toEqual({ ...reserved, arguments: '{"cmd":"printf hello"}' }); + }); + + test("exec_command input repair stays fail-closed outside the exact schema and payload", () => { + const declaration = (parameters: Record, namespace?: string) => ({ + type: "function", + name: "exec_command", + ...(namespace ? { namespace } : {}), + parameters, + }); + const canonical = { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }; + const cases: Array<{ payload: string; tool?: ReturnType }> = [ + { payload: '{"cmd":"already canonical"}' }, + { payload: '{"input":"one","cwd":"/tmp"}' }, + { payload: '{"input":7}' }, + { payload: '{"input":"one","cmd":"two"}' }, + { payload: '{"input":"one"', }, + { payload: '{"input":"one"}', tool: declaration({ ...canonical, required: [] }) }, + { payload: '{"input":"one"}', tool: declaration({ ...canonical, properties: { cmd: { type: "number" } } }) }, + ]; + for (const entry of cases) { + const tool = entry.tool ?? declaration(canonical); + const map = collectFunctionCallRepairSchemas({ tools: [tool] }); + const namespace = tool.namespace; + const call = item(entry.payload, { + name: namespace ? `${namespace}__exec_command` : "exec_command", + ...(namespace ? { namespace } : {}), + }); + expect(repairFunctionCalls(call, map).value).toBe(call); + } + + // A namespace is declared by its group, not by a `namespace` property on a + // top-level function. Exercise the actual remote identity so this proves the + // bare-only repair guard rather than passing because schema lookup missed. + const remoteMap = collectFunctionCallRepairSchemas({ tools: [{ + type: "namespace", + name: "remote", + tools: [declaration(canonical)], + }] }); + const remoteCall = item('{"input":"one"}', { + name: "exec_command", + namespace: "remote", + }); + expect(repairFunctionCalls(remoteCall, remoteMap).value).toBe(remoteCall); + }); + test.each([" ", "{", '{"cell_id":4.5}', '{"yield_time_ms":1.5}', '{"union":4.0}', '{"cell_id":9007199254740993}', '{"cell_id":4,"unknown":9007199254740993}', '{"cell_id":4,"unknown":1e400}']) ("preserves invalid/disagreeing/unsafe payload %s", argumentsText => { @@ -269,6 +340,36 @@ describe("native function completion SSE", () => { } finally { rewrite.dispose?.(); budget.dispose(); } }); + test("repairs exec_command input only at authoritative SSE completion boundaries", () => { + const map = collectFunctionCallRepairSchemas({ tools: [{ + type: "function", + name: "exec_command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }, + }] }); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(map); + const shellItem = item("", { name: "exec_command", status: "in_progress" }); + try { + const added = frame("response.output_item.added", { output_index: 0, item: shellItem }); + const delta = frame("response.function_call_arguments.delta", { + item_id: "fc_one", + delta: '{"input":"printf hello"}', + }); + expect(rewrite(added)).toEqual([added]); + expect(rewrite(delta)).toEqual([delta]); + const done = rewrite(frame("response.function_call_arguments.done", { + item_id: "fc_one", + arguments: '{"input":"printf hello"}', + })); + expect(payload(done[0]!)).toMatchObject({ arguments: '{"cmd":"printf hello"}' }); + } finally { + rewrite.dispose?.(); + } + }); + test.each(["response.output_item.done", "response.completed"])("no-arg %s works without arguments.done", type => { const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); try { From 403b6b2ac20dd09f5ba52ab681ab1317e6f743dd Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 17:01:20 +0900 Subject: [PATCH 84/87] test(ci): derive the release-verify fixture's signed set from the updater table (#5425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev went red at the union of #5405 and #5391: lane F made the deb a second Linux updater target, so the verifier's derived expected set gained OpenCodex--linux-amd64.deb.sig, while the test's hand-written oracle still described the earlier world where only the AppImage was signed. Each branch was green alone; the merge was not. The fix is derivation, not list-keeping. The signed set and the manifest platform list in the fixture now come straight from platformFiles — the table that decides which bundles carry the updater key — and the produced payload list comes from the shared standalone target module and the bundle table. A future updater target changes both sides of the assertion by itself. The derivation test keeps its concrete payload anchors (a renamed or dropped bundle should still fail for a human to review) and asserts the rule instead of the roster: a bundle's signature is expected exactly when the updater table names it. Only the two test oracles changed; the verification ordering (checksums, signatures and the manifest all precede publication) is untouched. --- .../release-desktop-scripts.test.ts | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 52ed2d90919..60c51e2cd30 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -3,15 +3,15 @@ import { createHash, generateKeyPairSync, sign as ed25519Sign } from "node:crypt import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; +import { bundlesByTarget, collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; import { runBuildLocal, summarizeAttempts, type ArtifactEntry, type BuildLocalDeps, } from "../../desktop/scripts/build-local"; -import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; -import { standaloneTargets } from "../../scripts/standalone-targets"; +import { buildUpdaterManifest, platformFiles, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; +import { standaloneArchiveName, standaloneTargets } from "../../scripts/standalone-targets"; import { expectedReleaseAssets, parseMinisignPublicKey, @@ -504,9 +504,15 @@ describe("release asset verification", () => { ]) { expect(expected).toContain(name); } - // Only the updater targets carry signatures; the DMG and the deb never do. - expect(expected).not.toContain(`OpenCodex-${VERSION}-macos.dmg.sig`); - expect(expected).not.toContain(`OpenCodex-${VERSION}-linux-amd64.deb.sig`); + // Signature presence follows the updater table exactly: a bundle is signed + // precisely when platformFiles names it as an updater target, so a new updater + // target changes this contract by itself rather than needing a hand edit here. + const updaterSuffixes = new Set(Object.values(platformFiles)); + for (const bundle of desktopTargets.flatMap(target => bundlesByTarget[target]!)) { + expect(expected).toContain(`OpenCodex-${VERSION}-${bundle.name}`); + expect(expected.includes(`OpenCodex-${VERSION}-${bundle.name}.sig`)) + .toBe(updaterSuffixes.has(bundle.name)); + } expect(expected.some(name => name.includes("/"))).toBe(false); }); @@ -584,27 +590,25 @@ describe("release asset verification", () => { const dir = join(root, "dist", "release"); mkdirSync(dir, { recursive: true }); - // The fixture is an independent producer oracle, written out by hand: five - // standalone archives, five desktop bundles, and signatures on exactly the - // three updater targets. Building it with the function under test would hide - // an omission in the expected set. + // The fixture derives from the producer tables — the standalone target module, + // the bundle table, and the updater platform table — assembled independently + // of the function under test. Building it with expectedReleaseAssets would + // hide an omission in the expected set; hand-writing it would go stale the + // next time a target is added (which is exactly the union failure this test + // once carried: the deb became an updater target and this oracle missed its + // signature). + const desktopTargets = releaseMatrixTargets( + readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8"), + ).desktopTargets; const produced = [ - `ocx-${VERSION}-bun-darwin-arm64.tar.gz`, - `ocx-${VERSION}-bun-darwin-x64.tar.gz`, - `ocx-${VERSION}-bun-windows-x64.zip`, - `ocx-${VERSION}-bun-linux-x64.tar.gz`, - `ocx-${VERSION}-bun-linux-arm64.tar.gz`, - `OpenCodex-${VERSION}-macos.dmg`, - `OpenCodex-${VERSION}-macos.app.tar.gz`, - `OpenCodex-${VERSION}-windows-x64.msi`, - `OpenCodex-${VERSION}-linux-x86_64.AppImage`, - `OpenCodex-${VERSION}-linux-amd64.deb`, + ...standaloneTargets.map(target => standaloneArchiveName(VERSION, target)), + ...desktopTargets.flatMap(target => + bundlesByTarget[target]!.map(bundle => `OpenCodex-${VERSION}-${bundle.name}`)), ]; - const signed = new Set([ - `OpenCodex-${VERSION}-macos.app.tar.gz`, - `OpenCodex-${VERSION}-windows-x64.msi`, - `OpenCodex-${VERSION}-linux-x86_64.AppImage`, - ]); + const updaterSuffixes = new Set(Object.values(platformFiles)); + const signed = new Set( + produced.filter(name => updaterSuffixes.has(name.slice(`OpenCodex-${VERSION}-`.length))), + ); for (const name of produced) { writeAsset(dir, name, Buffer.from(`payload:${name}`)); if (signed.has(name)) { @@ -616,9 +620,7 @@ describe("release asset verification", () => { // must be exactly the produced payloads plus their companions. const expected = expectedReleaseAssets({ version: VERSION, - desktopTargets: releaseMatrixTargets( - readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8"), - ).desktopTargets, + desktopTargets, requireSignatures: true, }); const oracle = produced.flatMap(name => @@ -642,9 +644,9 @@ describe("release asset verification", () => { expect(receipt.checksumsVerified) .toBe(produced.length); expect(receipt.signaturesVerified).toBe(signed.size); - expect(receipt.manifestPlatforms).toEqual([ - "darwin-aarch64", "darwin-x86_64", "linux-x86_64", "windows-x86_64", - ]); + // Same rule as the signed set: the platform list is the updater table's keys, + // not a copy of them. + expect(receipt.manifestPlatforms).toEqual(Object.keys(platformFiles).sort()); expect(JSON.parse(readFileSync(receiptPath, "utf8"))).toEqual(receipt); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { From 1687636193d3559c2bc7cc37f427dea71e693c34 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 17:24:10 +0900 Subject: [PATCH 85/87] fix(runtime): close ownership mutation races (#5426) * test(service): assert ownership parser behavior * fix(service): fence runtime start and stop ownership * fix(update): hold runtime authority through replacement * docs(runtime): record ownership mutation boundaries * test(cli): follow transactional start prewarm * test(cli): anchor fenced start refusal * test(update): assert lock boundary behavior --- bin/ocx.mjs | 555 +++++++++++------- scripts/test-layout/layout.json | 1 + src/cli/index.ts | 186 +++--- src/cli/start-ownership-publication.ts | 56 ++ src/server/index.ts | 13 +- src/server/index/spend-ledger-lifecycle.ts | 42 +- src/server/lifecycle.ts | 8 +- src/service/ownership-mutation-lease.d.mts | 9 + src/service/ownership-mutation-lease.mjs | 10 + src/update/runtime-ownership.d.mts | 30 + src/update/runtime-ownership.mjs | 47 ++ structure/runtime.md | 15 +- tests/cli/cli-catalog-prewarm.test.ts | 16 +- tests/cli/cli-dispatch.test.ts | 9 +- tests/cli/cli-ready.test.ts | 18 +- tests/cli/start-ownership-publication.test.ts | 83 +++ .../clients/desktop-install-identity.test.ts | 16 +- tests/fixtures/test-layout-expected.json | 1 + .../loopback-listener-integration.test.ts | 8 +- tests/server/spend-ledger-lifecycle.test.ts | 12 +- tests/service/service-ownership-state.test.ts | 14 + tests/update/update-desktop-owner.test.ts | 92 ++- tests/update/update-stop-first.test.ts | 39 +- 23 files changed, 912 insertions(+), 368 deletions(-) create mode 100644 src/cli/start-ownership-publication.ts create mode 100644 tests/cli/start-ownership-publication.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 1e483f754ee..fef7686638c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -12,13 +12,21 @@ import { spawn, spawnSync } from "node:child_process"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; -import { planUpdateRuntimeHandling } from "../src/update/runtime-ownership.mjs"; +import { + inspectPackageRuntimeLiveness, + planStoppedRuntimeRecovery, + planUpdateRuntimeHandling, +} from "../src/update/runtime-ownership.mjs"; import { inspectInstallStateBytes, selectAuthoritativeServiceState, serviceStateFilesFor, } from "../src/service/install-state-contract.mjs"; -import { acquireOwnershipMutationLease } from "../src/service/ownership-mutation-lease.mjs"; +import { + acquireOwnershipMutationLease, + ownershipMutationLeaseChildEnvironment, + unprivilegedOwnershipMutationEnvironment, +} from "../src/service/ownership-mutation-lease.mjs"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -48,6 +56,9 @@ import { } from "../src/update/codex-cli-update-launch-policy.mjs"; const PKG = "@bitkyc08/opencodex"; +const UPDATE_RECOVERY_READY_MS = 30_000; +const UPDATE_RECOVERY_POLL_MS = 100; +const UPDATE_RECOVERY_SLEEP = new Int32Array(new SharedArrayBuffer(4)); try { process.cwd(); } catch { @@ -290,8 +301,13 @@ function runPackageManagerSelfUpdate(manager) { : ["none", selected.revision]), }; }; + const ownershipIdentity = observation => observation.ownershipUnknown + ? null + : JSON.stringify(observation.ownership + ? ["owned", observation.ownership.owner, observation.ownership.installId, observation.ownership.consentGeneration] + : ["none"]); const initialOwnership = readOwnership(); - const runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); + let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); if (runtimePlan.notice) console.log(runtimePlan.notice); if (!runtimePlan.mayReplacePackage) { console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); @@ -342,39 +358,45 @@ function runPackageManagerSelfUpdate(manager) { } } - // Capture listen target before stop clears runtime-port.json (mirrors GUI/CLI update worker). - // Do not treat a live runtime port of 10100 as "missing" — track whether the read succeeded. + function readCurrentRuntimeTarget() { + let raw; + try { + raw = readFileSync(join(configDir(), "runtime-port.json"), "utf8"); + } catch (error) { + return error && typeof error === "object" && "code" in error && error.code === "ENOENT" + ? { kind: "absent" } + : { kind: "unknown" }; + } + try { + const rt = JSON.parse(raw); + const pid = Number(rt?.pid); + if (!Number.isFinite(rt?.port) || rt.port <= 0 || rt.port > 65535 + || !Number.isSafeInteger(pid) || pid <= 0) return { kind: "unknown" }; + return { kind: "target", target: { + pid, + port: Math.trunc(rt.port), + hostname: typeof rt.hostname === "string" && rt.hostname.trim() !== "" + ? rt.hostname.trim() + : null, + } }; + } catch { return { kind: "unknown" }; } + } + + // Capture the recovery target before stop clears runtime-port.json. Replacement safety + // re-reads this record under the mutation lease instead of trusting this snapshot. let bakePort = 10100; // The hostname travels with the port: a proxy bound to ::1 or a specific interface is // invisible to a probe that assumes 127.0.0.1, and "no answer" would then read as // "stopped" for exactly the proxy the probe exists to find. let bakeHostname = "127.0.0.1"; - let sawRuntimePort = false; - let sawRuntimeHostname = false; - try { - const rt = JSON.parse(readFileSync(join(configDir(), "runtime-port.json"), "utf8")); - if (Number.isFinite(rt?.port) && rt.port > 0 && rt.port <= 65535) { - // Only trust runtime when its pid still looks alive (stale crash leftovers fall back to config). - const rtPid = Number(rt?.pid); - let runtimeLive = false; - if (Number.isSafeInteger(rtPid) && rtPid > 0) { - try { - process.kill(rtPid, 0); - runtimeLive = true; - } catch (e) { - if (e && typeof e === "object" && "code" in e && e.code === "EPERM") runtimeLive = true; - } - } - if (runtimeLive) { - bakePort = Math.trunc(rt.port); - if (typeof rt?.hostname === "string" && rt.hostname.trim() !== "") { - bakeHostname = rt.hostname.trim(); - sawRuntimeHostname = true; - } - sawRuntimePort = true; - } - } - } catch { /* fall through to config */ } + const initialRuntimeObservation = readCurrentRuntimeTarget(); + const initialRuntimeTarget = initialRuntimeObservation.kind === "target" ? initialRuntimeObservation.target : null; + let sawRuntimePort = initialRuntimeTarget !== null; + let sawRuntimeHostname = initialRuntimeTarget?.hostname !== null && initialRuntimeTarget?.hostname !== undefined; + if (initialRuntimeTarget) { + bakePort = initialRuntimeTarget.port; + if (initialRuntimeTarget.hostname) bakeHostname = initialRuntimeTarget.hostname; + } // Port and hostname resolve INDEPENDENTLY: a legacy runtime record carries a port and no // hostname, and skipping config in that case probed 127.0.0.1 for a proxy bound to ::1. if (!sawRuntimePort || bakeHostname === "127.0.0.1") { @@ -390,6 +412,18 @@ function runPackageManagerSelfUpdate(manager) { } // Wildcard and bracketed-IPv6 normalization lives in probeProxyLiveness, so both lanes // get it from one place. + function currentPackageRuntimeLiveness() { + return inspectPackageRuntimeLiveness({ + capturedTarget: { port: bakePort, hostname: bakeHostname }, + readCurrentTarget: () => { + const current = readCurrentRuntimeTarget(); + return current.kind === "target" + ? { kind: "target", target: { port: current.target.port, hostname: current.target.hostname ?? bakeHostname } } + : current; + }, + probe: target => probeProxyLiveness(target.port, target.hostname), + }).overall; + } const launcher = fileURLToPath(import.meta.url); // The pnpm owner preflight has verified this package tree and global group. Keep that exact @@ -399,13 +433,17 @@ function runPackageManagerSelfUpdate(manager) { ? join(owner.packagePath, "bin", "ocx.mjs") : launcher; let postUpdateLauncherUsable = true; + let delegatedOwnershipMutationToken = null; + const mutationChildEnvironment = () => delegatedOwnershipMutationToken + ? ownershipMutationLeaseChildEnvironment(process.env, delegatedOwnershipMutationToken) + : unprivilegedOwnershipMutationEnvironment(process.env); function startProxyDirectly() { if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); - return; + return false; } - const env = { ...process.env }; + const env = mutationChildEnvironment(); delete env.OCX_SERVICE; console.log(`Attempting to restart the proxy on port ${bakePort}.`); const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], { @@ -418,13 +456,24 @@ function runPackageManagerSelfUpdate(manager) { console.error(`opencodex: direct proxy restart failed: ${error.message}`); }); child.unref(); + const deadline = Date.now() + UPDATE_RECOVERY_READY_MS; + while (Date.now() < deadline) { + const current = readCurrentRuntimeTarget(); + if (current.kind === "target" + && probeProxyLiveness(current.target.port, current.target.hostname ?? bakeHostname) === "live") return true; + Atomics.wait(UPDATE_RECOVERY_SLEEP, 0, 0, UPDATE_RECOVERY_POLL_MS); + } + console.error("opencodex: the recovery proxy did not publish a healthy runtime before the recovery deadline."); + return false; } function refreshBackgroundServiceOrStartDirect() { const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { - let svc = spawnSync(process.execPath, serviceRefreshArgs(), { stdio: "inherit", windowsHide: true }); + let svc = spawnSync(process.execPath, serviceRefreshArgs(), { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); // `serviceWasInstalled` is inferred from service-state.json alone, which can be // STALE — present while the registration is gone. Repair refuses that case by // design, and its thrown Error is indistinguishable from any other failure at @@ -435,7 +484,9 @@ function runPackageManagerSelfUpdate(manager) { // could re-register a service the user just uninstalled. if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) { console.log("No registered service found — installing it instead."); - svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true }); + svc = spawnSync(process.execPath, serviceInstallArgs(), { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); } let needDirectStart = svc.status !== 0; if (!needDirectStart) { @@ -487,210 +538,263 @@ function runPackageManagerSelfUpdate(manager) { } } - // Never replace package files under a live proxy — stop it first (full `ocx stop` - // semantics: graceful drain, service stop, native Codex restore). Gate on the service - // and the runtime-port record too: a service-managed or orphaned proxy can be live - // while ocx.pid is stale/missing. - if (trayBeforeUpdate.stopBeforeReplacement) { - console.log("⏹ Handing off the Windows tray before updating..."); - try { - handoffWindowsTrayForUpdate(trayBeforeUpdate, { - stop: () => { - const stopped = runTrayLifecycle(launcher, "stop"); - return { exitStatus: stopped.status, running: trayInstallState().running }; - }, - start: () => runTrayLifecycle(launcher, "start"), - }); - } catch { - console.error("opencodex: could not stop the Windows tray; aborting before package replacement."); + const updateLease = acquireOwnershipMutationLease(serviceStatePaths); + delegatedOwnershipMutationToken = updateLease.token; + let updateLeaseReleased = false; + const releaseUpdateLease = () => { + if (updateLeaseReleased) return; + updateLeaseReleased = true; + delegatedOwnershipMutationToken = null; + updateLease.release(); + }; + + let res; + try { + // Stop authority is decided under the same lease the child joins. A takeover between the + // earlier preflight and this boundary therefore blocks stop before it is sent. + const lockedOwnership = readOwnership(); + const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled }); + if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { + releaseUpdateLease(); + console.error(lockedPlan.notice + ?? "opencodex: update stopped because runtime ownership changed before stop authorization; rerun from the beginning."); process.exit(1); } - } - const hasRuntimeState = - existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); - - function recoverStoppedRuntimeAfterFailure() { - // Nothing was stopped under a foreign owner, so there is nothing to recover — and - // starting a proxy here would put a second one beside the runtime the app is managing. - const recoveryPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); - if (!recoveryPlan.mayStopRuntime) return; - if (!postUpdateLauncherUsable) { - console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); - return; + runtimePlan = lockedPlan; + const stoppedOwnershipIdentity = ownershipIdentity(lockedOwnership); + + // Never replace package files under a live proxy — stop it first (full `ocx stop` + // semantics: graceful drain, service stop, native Codex restore). Gate on the service + // and the runtime-port record too: a service-managed or orphaned proxy can be live + // while ocx.pid is stale/missing. + if (trayBeforeUpdate.stopBeforeReplacement) { + console.log("⏹ Handing off the Windows tray before updating..."); + try { + handoffWindowsTrayForUpdate(trayBeforeUpdate, { + stop: () => { + const stopped = runTrayLifecycle(launcher, "stop"); + return { exitStatus: stopped.status, running: trayInstallState().running }; + }, + start: () => runTrayLifecycle(launcher, "start"), + }); + } catch { + releaseUpdateLease(); + console.error("opencodex: could not stop the Windows tray; aborting before package replacement."); + process.exit(1); + } } - if (recoveryPlan.mayRestoreService) { - console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service."); - refreshBackgroundServiceOrStartDirect(); - } else if (hasRuntimeState) { - console.warn("opencodex: update failed after stopping the proxy — restarting the previous version directly."); - startProxyDirectly(); + const hasRuntimeState = + existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); + let stopAttempted = false; + + function recoverStoppedRuntimeAfterFailure(reason) { + const recoveryOwnership = readOwnership(); + const recoveryLiveness = currentPackageRuntimeLiveness(); + const recovery = planStoppedRuntimeRecovery({ + stopAttempted, + ...recoveryOwnership, + sameOwner: ownershipIdentity(recoveryOwnership) === stoppedOwnershipIdentity, + liveness: recoveryLiveness, + serviceInstalled: serviceWasInstalled, + launcherUsable: postUpdateLauncherUsable, + hadRuntimeState: hasRuntimeState, + }); + if (recovery.reason === "ownership-unknown") { + console.error(`opencodex: ${reason}; runtime ownership is unknown, so automatic recovery was refused. Run 'ocx status --json' and repair the service-state record before retrying.`); + } else if (recovery.reason === "ownership-transferred") { + console.log("opencodex: runtime ownership moved to another installation; the stopped CLI runtime was not revived."); + } else if (recovery.reason.startsWith("runtime-")) { + console.error(`opencodex: ${reason}; package runtime liveness is ${recoveryLiveness}, so automatic recovery was refused.`); + } else if (recovery.reason === "launcher-unavailable") { + console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); + } else if (recovery.action === "service") { + console.warn(`opencodex: ${reason} after stopping the proxy — restoring the previous background service.`); + refreshBackgroundServiceOrStartDirect(); + } else if (recovery.action === "direct") { + console.warn(`opencodex: ${reason} after stopping the proxy — restarting the previous version directly.`); + startProxyDirectly(); + } + return recovery; } - } - // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a - // parent crashed mid-deferral the service, pid and runtime records can all be absent - // while the shared client config still points at a proxy that is gone; installing over - // that silently skips the recovery the receipt was written to trigger (#3008). Presence - // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides - // whether the obligation is safe to finish. - const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); - if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)) { - console.log("⏹ Stopping the running proxy before updating..."); - const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true }); - const stillHasRuntimeState = - existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); - // A history-only failure means teardown succeeded and a backup manifest is waiting for - // review: the proxy is down and replacing package files is safe. Every other nonzero - // status is a stop that did not finish, and a signal kill (status null) says nothing - // about whether it did - both abort, because replacing files under a live server - // leaves it running mixed old and new modules (#3008). - // The same decision the Bun updater makes, from the same module (#3008). Absent PID and - // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts - // because a silent listener is exactly the state where replacing files is dangerous. - const decision = decidePostStopUpdate({ - status: stopRes.status, - hasRuntimeState: stillHasRuntimeState, - // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed - // (there is nothing left to stop), so a pre-stop check alone let the retry install - // over a teardown that never ran. - teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()), - liveness: probeProxyLiveness(bakePort, bakeHostname), - }); - const historyOnlyStop = decision.reason === "history-only"; - if (!decision.proceed) { - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); - if (decision.reason === "teardown-outstanding") { - console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); - console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home."); - } else console.error(decision.reason === "proxy-unknown" - ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.` - : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a + // parent crashed mid-deferral the service, pid and runtime records can all be absent + // while the shared client config still points at a proxy that is gone; installing over + // that silently skips the recovery the receipt was written to trigger (#3008). Presence + // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides + // whether the obligation is safe to finish. + const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); + const stopNeeded = serviceWasInstalled || hasRuntimeState || hasPendingTeardown; + if (stopNeeded && !runtimePlan.mayStopRuntime) { + releaseUpdateLease(); + console.error(runtimePlan.notice + ?? "opencodex: update stopped because this installation may not stop the current runtime."); process.exit(1); } - if (historyOnlyStop || historyRestoreIncomplete()) { - console.warn( - "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + - " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + - " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.", - ); - } - if (decision.reason === "history-deferred") { - // The reported #4718 path is this lane. Nothing was restored, so this is a different - // sentence from the manifest warning above: an operator told "history metadata is - // incomplete" would assume config and catalog already came back. - console.warn( - "opencodex: WARNING — the shared teardown was refused by the Codex history preflight and restored nothing.\n" + - " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" + - " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.", - ); + if (stopNeeded) { + stopAttempted = true; + console.log("⏹ Stopping the running proxy before updating..."); + const stopRes = spawnSync(process.execPath, [launcher, "stop"], { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); + const stillHasRuntimeState = + existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); + // A history-only failure means teardown succeeded and a backup manifest is waiting for + // review: the proxy is down and replacing package files is safe. Every other nonzero + // status is a stop that did not finish, and a signal kill (status null) says nothing + // about whether it did - both abort, because replacing files under a live server + // leaves it running mixed old and new modules (#3008). + // The same decision the Bun updater makes, from the same module (#3008). Absent PID and + // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts + // because a silent listener is exactly the state where replacing files is dangerous. + const decision = decidePostStopUpdate({ + status: stopRes.status, + hasRuntimeState: stillHasRuntimeState, + // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed + // (there is nothing left to stop), so a pre-stop check alone let the retry install + // over a teardown that never ran. + teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()), + liveness: probeProxyLiveness(bakePort, bakeHostname), + }); + const historyOnlyStop = decision.reason === "history-only"; + if (!decision.proceed) { + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (decision.reason === "teardown-outstanding") { + console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); + console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home."); + } else console.error(decision.reason === "proxy-unknown" + ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.` + : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + releaseUpdateLease(); + process.exit(1); + } + if (historyOnlyStop || historyRestoreIncomplete()) { + console.warn( + "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + + " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + + " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.", + ); + } + if (decision.reason === "history-deferred") { + // The reported #4718 path is this lane. Nothing was restored, so this is a different + // sentence from the manifest warning above: an operator told "history metadata is + // incomplete" would assume config and catalog already came back. + console.warn( + "opencodex: WARNING — the shared teardown was refused by the Codex history preflight and restored nothing.\n" + + " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" + + " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.", + ); + } } - } - const replacementLease = acquireOwnershipMutationLease(serviceStatePaths); - const replacementOwnership = readOwnership(); - const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); - const replacementLiveness = runtimePlan.mayStopRuntime - ? probeProxyLiveness(bakePort, bakeHostname) - : "dead"; - if (replacementOwnership.subjectToken !== initialOwnership.subjectToken - || !replacementPlan.mayReplacePackage - || replacementLiveness !== "dead") { - replacementLease.release(); - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); - console.error(replacementPlan.notice - ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); - process.exit(1); - } + const replacementOwnership = readOwnership(); + const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); + const replacementLiveness = currentPackageRuntimeLiveness(); + if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + || !replacementPlan.mayReplacePackage + || replacementLiveness !== "dead") { + recoverStoppedRuntimeAfterFailure("replacement was refused"); + releaseUpdateLease(); + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + console.error(replacementPlan.notice + ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); + process.exit(1); + } - // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a - // content-addressable store and generated global shims, so its path uses pnpm's own - // global update operation and verifies the active group instead of renaming files. - console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`); - let res; - try { - if (manager === "npm") { - const packageDir = resolve(here, ".."); - const tx = transactionalNpmUpdate({ - packageDir, - pkgName: PKG, - targetVersion: latest || undefined, - tag, - runNpm: (args) => { - const invocation = npmInvocation(args); - if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { - stdio: "inherit", - timeout: 180000, - windowsHide: true, - ...invocation.options, - }); - }, - log: (line) => console.log(line), - }); - postUpdateLauncherUsable = tx.ok - || tx.rolledBack === true - || ["stage", "verify", "swap-backup"].includes(tx.phase); - if (tx.ok) { - res = { status: 0 }; - } else if (tx.phase === "stage" || tx.phase === "verify") { - // Live tree untouched: report and stop. Nothing to roll back. - console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); - res = { status: 1 }; - } else { - console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); - res = { status: 1 }; - } - } else { - const update = runPnpmGlobalUpdate({ - packageName: PKG, - currentVersion: current, - targetVersion: latest || undefined, - tag, - owner, - runningPackagePath: resolve(here, ".."), - runPnpm: (args, capture = false) => { - const invocation = pnpmOwnerInvocation(owner, args); - if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { - stdio: capture ? "pipe" : "inherit", - encoding: "utf8", - timeout: 180000, - windowsHide: true, - env: invocation.env, - ...invocation.options, - }); - }, - log: line => console.log(line), - }); - if (update.ok) { - // pnpm switches the active global group and updates its shim. Continue recovery - // through that fresh package tree, not the old group whose launcher is still - // executing this update. - postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); - res = { status: 0 }; + // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a + // content-addressable store and generated global shims, so its path uses pnpm's own + // global update operation and verifies the active group instead of renaming files. + console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`); + try { + if (manager === "npm") { + const packageDir = resolve(here, ".."); + const tx = transactionalNpmUpdate({ + packageDir, + pkgName: PKG, + targetVersion: latest || undefined, + tag, + runNpm: (args) => { + const invocation = npmInvocation(args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + ...invocation.options, + stdio: "inherit", + timeout: 180000, + windowsHide: true, + env: unprivilegedOwnershipMutationEnvironment(invocation.options?.env ?? process.env), + }); + }, + log: (line) => console.log(line), + }); + postUpdateLauncherUsable = tx.ok + || tx.rolledBack === true + || ["stage", "verify", "swap-backup"].includes(tx.phase); + if (tx.ok) { + res = { status: 0 }; + } else if (tx.phase === "stage" || tx.phase === "verify") { + // Live tree untouched: report and stop. Nothing to roll back. + console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); + res = { status: 1 }; + } else { + console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); + res = { status: 1 }; + } } else { - console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); - postUpdateLauncherUsable = Boolean(update.activePath); - if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); - res = { status: 1 }; + const update = runPnpmGlobalUpdate({ + packageName: PKG, + currentVersion: current, + targetVersion: latest || undefined, + tag, + owner, + runningPackagePath: resolve(here, ".."), + runPnpm: (args, capture = false) => { + const invocation = pnpmOwnerInvocation(owner, args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + ...invocation.options, + stdio: capture ? "pipe" : "inherit", + encoding: "utf8", + timeout: 180000, + windowsHide: true, + env: unprivilegedOwnershipMutationEnvironment(invocation.env ?? process.env), + }); + }, + log: line => console.log(line), + }); + if (update.ok) { + // pnpm switches the active global group and updates its shim. Continue recovery + // through that fresh package tree, not the old group whose launcher is still + // executing this update. + postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); + res = { status: 0 }; + } else { + console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); + postUpdateLauncherUsable = Boolean(update.activePath); + if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); + res = { status: 1 }; + } } + } catch (error) { + // An unexpected throw means we cannot prove the live tree is untouched, so the + // legacy in-place install (which deletes live first) is exactly the wrong rescue — + // it recreates the #1849 destruction path. Report and stop; the boot probe and the + // recovery marker cover the swap-window states. + const manual = manager === "pnpm" + ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` + : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; + // An unexpected exception leaves the active package path unproven for either manager. + // Do not run service/tray/proxy recovery through a possibly half-swapped tree. + postUpdateLauncherUsable = false; + console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` + + `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); + res = { status: 1 }; } - } catch (error) { - // An unexpected throw means we cannot prove the live tree is untouched, so the - // legacy in-place install (which deletes live first) is exactly the wrong rescue — - // it recreates the #1849 destruction path. Report and stop; the boot probe and the - // recovery marker cover the swap-window states. - const manual = manager === "pnpm" - ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` - : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; - // An unexpected exception leaves the active package path unproven for either manager. - // Do not run service/tray/proxy recovery through a possibly half-swapped tree. - postUpdateLauncherUsable = false; - console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` + - `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); - res = { status: 1 }; + if (res.status !== 0) recoverStoppedRuntimeAfterFailure("update failed"); + } finally { + // Expected aborts release before process.exit(); this covers every thrown or newly-added + // path and keeps token restoration coupled to the lease itself. + releaseUpdateLease(); } - replacementLease.release(); const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); @@ -716,7 +820,6 @@ function runPackageManagerSelfUpdate(manager) { process.exit(0); } if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); - recoverStoppedRuntimeAfterFailure(); const manual = manager === "pnpm" ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f79307b4633..3905c739ac9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -183,6 +183,7 @@ "provider-egress-fetch.test.ts": "responses", "provider-egress-management-validation.test.ts": "server", "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", diff --git a/src/cli/index.ts b/src/cli/index.ts index b98a28b1e78..a9b411e9d32 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -104,6 +104,10 @@ import { startHistoryMigrationGuardian } from "../codex/history-migration-guardi import { maybeShowStarPrompt } from "./star-prompt"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; +import { + bindAndPublishStartOwnership, + StartOwnershipRollbackUncertainError, +} from "./start-ownership-publication"; import { syncModelsToCodex } from "../codex/sync"; import { HUB_GATED_SKIP_MESSAGE, @@ -222,6 +226,13 @@ function startArgv(port?: number): string[] { return selfLaunchArgv(args); } +class StartCommandExit extends Error { + constructor(readonly exitCode: number) { + super(`start command exited with code ${exitCode}`); + this.name = "StartCommandExit"; + } +} + async function chooseListenPort( requestedPort?: number, options: { sibling?: boolean } = {}, @@ -294,17 +305,17 @@ async function chooseListenPort( // Same contract as the pre-bind owner check: the wrapper's retry loop terminates // on a zero exit, and the port it was asked to serve is already served. console.log(`Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}); service wrapper staying out of the way.`); - process.exit(0); + throw new StartCommandExit(0); } if (decision === "refuse-live-proxy") { console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`); - process.exit(1); + throw new StartCommandExit(1); } if (decision === "refuse-unidentified-holder") { console.error(`❌ Port ${preferred} is busy and its holder did not identify as opencodex.`); console.error(" Starting on another port would leave Codex pointed at a proxy you did not ask for."); console.error(" Stop whatever holds that port, or start on a free one with 'ocx start --port '."); - process.exit(1); + throw new StartCommandExit(1); } if (preferred > 0) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); @@ -319,7 +330,7 @@ async function chooseListenPort( if (err instanceof PortUnavailableError) { console.error(`❌ ${err.message}`); console.error(" Stop whatever holds that port, or change config.port, then retry."); - process.exit(1); + throw new StartCommandExit(1); } throw err; } @@ -449,82 +460,99 @@ async function handleStart(options: { block?: boolean } = {}) { // live daemon holding resources while it overwrites its own binary. await maybeShowUpdatePrompt(); - const startLease = acquireOwnershipMutationLease(serviceStatePaths()); - // The earlier probe owned journal cleanup. This one owns the bind decision: an updater may - // have stopped the old runtime and acquired the same lease before package replacement. - const fencedLive = await findLiveProxy(START_OWNERSHIP_LIVENESS); - if (fencedLive) { - const decision = decideStartWithLiveOwner({ - livePort: fencedLive.port, - requestedPort, - ocxService: process.env.OCX_SERVICE, - }); - if (decision === "service-stay-out") { - startLease.release(); - console.log(`Proxy already running (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}); service wrapper staying out of the way.`); - process.exit(0); - } - if (decision === "refuse") { - startLease.release(); - console.error(`⚠️ Proxy appeared before bind (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}). Use 'ocx stop' first.`); - process.exit(1); - } - siblingStart = true; - } - - // Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port - // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries - // the same port only (never hop — that was the remaining PR #152 gap). - let port = await chooseListenPort(requestedPort, { sibling: siblingStart }); - const { drainAndShutdown, isRecyclingForExit, startServer } = await import("../server"); - // One private readiness gate for this startServer invocation, captured by the - // listener's closure. handleStart owns it and transitions it after the - // post-startup sync settles. A second startServer in the same process would - // get its own gate and could never reset/mutate this one. - const readinessGate = createReadinessGate(); - let server: ReturnType; - const localAttestationSecret = createLocalAttestationSecret(); + type StartServerModule = typeof import("../server"); + type BoundStart = { + server: ReturnType; + serverModule: StartServerModule; + port: number; + readinessGate: ReturnType; + localAttestationSecret: string; + config: ReturnType; + }; + let boundStart: BoundStart; try { - for (let attempt = 0; ; attempt++) { - try { - server = startServer(port, { localAttestationSecret, readinessGate }); - // Prewarm the live provider model cache as soon as the port is bound so the - // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight - // instead of racing duplicate upstream /models fetches. - scheduleCatalogPrewarm(); - break; - } catch (err) { - if (err instanceof SpendLedgerOwnerError) { - console.error(`❌ ${err.message}`); - startLease.release(); - process.exit(1); - } - if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; - if (requestedPort !== undefined) { - console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); - const hostname = loadConfig().hostname ?? "127.0.0.1"; - const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 }); - if (!freed) { - console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`); - startLease.release(); - process.exit(1); + boundStart = await bindAndPublishStartOwnership({ + acquireLease: () => acquireOwnershipMutationLease(serviceStatePaths()), + bind: async () => { + // The earlier probe owned journal cleanup. This one owns the bind decision: an + // updater may have stopped the old runtime and acquired this lease for replacement. + const fencedLive = await findLiveProxy(START_OWNERSHIP_LIVENESS); + if (fencedLive) { + const decision = decideStartWithLiveOwner({ + livePort: fencedLive.port, + requestedPort, + ocxService: process.env.OCX_SERVICE, + }); + if (decision === "service-stay-out") { + console.log(`Proxy already running (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}); service wrapper staying out of the way.`); + throw new StartCommandExit(0); + } + if (decision === "refuse") { + console.error(`⚠️ Proxy appeared before bind (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}). Use 'ocx stop' first.`); + throw new StartCommandExit(1); + } + siblingStart = true; } - continue; - } - console.log(`⚠️ Port ${port} was taken while starting; picking another...`); - port = await chooseListenPort(requestedPort, { sibling: siblingStart }); - } - } - } finally { - startLease.release(); + + // Port selection is check-then-bind. The lease prevents every cooperating start or + // updater from turning that check into a different ownership decision. + let port = await chooseListenPort(requestedPort, { sibling: siblingStart }); + const serverModule = await import("../server"); + const readinessGate = createReadinessGate(); + const localAttestationSecret = createLocalAttestationSecret(); + const config = loadConfig(); + let server: ReturnType; + for (let attempt = 0; ; attempt++) { + try { + server = serverModule.startServer(port, { localAttestationSecret, readinessGate }); + break; + } catch (err) { + try { await serverModule.waitForFailedStartRollback(err); } + catch (rollbackError) { + throw new StartOwnershipRollbackUncertainError([err, rollbackError]); + } + if (err instanceof SpendLedgerOwnerError) { + console.error(`❌ ${err.message}`); + throw new StartCommandExit(1); + } + if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; + if (requestedPort !== undefined) { + console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); + const hostname = config.hostname ?? "127.0.0.1"; + const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 }); + if (!freed) { + console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`); + throw new StartCommandExit(1); + } + continue; + } + console.log(`⚠️ Port ${port} was taken while starting; picking another...`); + port = await chooseListenPort(requestedPort, { sibling: siblingStart }); + } + } + return { server, serverModule, port, readinessGate, localAttestationSecret, config }; + }, + writePid: () => writePid(process.pid), + writeRuntime: bound => writeRuntimePort({ + pid: process.pid, + port: bound.port, + hostname: bound.config.hostname, + attestationSecret: bound.localAttestationSecret, + }), + stopBound: bound => bound.server.stop(true), + removeRuntime: () => removeRuntimePortIfPidIs(process.pid), + removePid: () => removePidIfValueIs(process.pid), + }); + } catch (error) { + if (error instanceof StartCommandExit) { process.exitCode = error.exitCode; return; } + throw error; } - // A single request's streaming error must never crash the daemon serving every - // other Codex session — capture the full stack to crash.log and stay up. - installCrashGuards(); - writePid(process.pid); - const config = loadConfig(); - writeRuntimePort({ pid: process.pid, port, hostname: config.hostname, attestationSecret: localAttestationSecret }); + const { server, serverModule, port, readinessGate, config } = boundStart; + const { drainAndShutdown, isRecyclingForExit } = serverModule; + // Records are visible now; background work may observe this runtime without a gap. + scheduleCatalogPrewarm(); + installCrashGuards(); // No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it // is about to transform; snapshotting earlier only captured a baseline that could // already be stale by the time injection ran (#477). @@ -956,6 +984,12 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole } async function handleStop() { + const lease = acquireOwnershipMutationLease(serviceStatePaths()); + try { return await handleStopUnlocked(); } + finally { lease.release(); } +} + +async function handleStopUnlocked() { // The receipt must name the endpoint the owner was stopping — an obligation nobody can // locate cannot be proven discharged. Only the runtime record knows it; a proxy started // with an explicit --port is not on the configured one. diff --git a/src/cli/start-ownership-publication.ts b/src/cli/start-ownership-publication.ts new file mode 100644 index 00000000000..33cdb428655 --- /dev/null +++ b/src/cli/start-ownership-publication.ts @@ -0,0 +1,56 @@ +export interface StartOwnershipLease { + release(): void; +} + +export class StartOwnershipRollbackUncertainError extends AggregateError { + constructor(errors: Iterable) { + super(errors, "start listener rollback could not be proven complete"); + this.name = "StartOwnershipRollbackUncertainError"; + } +} + +export interface StartOwnershipPublicationDeps { + acquireLease(): StartOwnershipLease; + bind(): Promise; + writePid(bound: TBound): void; + writeRuntime(bound: TBound): void; + stopBound(bound: TBound): void | Promise; + removeRuntime(): void; + removePid(): void; +} + +/** Bind and publish PID/runtime ownership as one lease-protected transaction. */ +export async function bindAndPublishStartOwnership( + deps: StartOwnershipPublicationDeps, +): Promise { + const lease = deps.acquireLease(); + let bound: TBound; + let releaseLease = true; + try { + try { bound = await deps.bind(); } + catch (error) { + if (error instanceof StartOwnershipRollbackUncertainError) releaseLease = false; + throw error; + } + try { + deps.writePid(bound); + deps.writeRuntime(bound); + } catch (error) { + const failures: unknown[] = [error]; + let stopFailed = false; + try { await deps.stopBound(bound); } + catch (failure) { stopFailed = true; failures.push(failure); } + try { deps.removeRuntime(); } catch (failure) { failures.push(failure); } + try { deps.removePid(); } catch (failure) { failures.push(failure); } + if (stopFailed) { + releaseLease = false; + throw new StartOwnershipRollbackUncertainError(failures); + } + if (failures.length > 1) throw new AggregateError(failures, "start ownership publication rollback failed"); + throw error; + } + return bound; + } finally { + if (releaseLease) lease.release(); + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 5ab9dccb34f..61fbcec7d8a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -201,12 +201,13 @@ import { detectInstall } from "../update/index"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle"; import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; -import { acquireSpendLedgerServerLifecycle, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; +import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; +export { waitForFailedStartRollback } from "./index/spend-ledger-lifecycle"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { const spendLedgerLifecycle = acquireSpendLedgerServerLifecycle(getConfigDir()); try { return startServerWithSpendLedgerOwner(port, deps, spendLedgerLifecycle); } - catch (error) { spendLedgerLifecycle.releaseAfterFailedStart(); throw error; } + catch (error) { recordFailedStartRollback(error, spendLedgerLifecycle.releaseAfterFailedStart()); throw error; } } function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartServerDeps, spendLedgerLifecycle: SpendLedgerServerLifecycle): Server { @@ -777,16 +778,16 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe } }, ], - async () => { + async listenersStopped => { try { await backgroundLifecycle.release(); await releaseNativeMainStartupLifecycle(server); } finally { // icacls.exe from hardenConfigDir() holds the config dir open; a caller that // removes the dir right after stop() settles would hit EPERM/EBUSY on Windows - // otherwise. Runs even when an earlier release rejected — that rejection still - // propagates, but not before the child is drained. - try { spendLedgerLifecycle.release(); } + // otherwise. Config hardening still flushes when an earlier release rejects. The + // spend owner is retained when a listener stop failed because the socket may live. + try { if (listenersStopped) spendLedgerLifecycle.release(); } finally { await flushConfigDirHardening(startupConfigDir); } } }, diff --git a/src/server/index/spend-ledger-lifecycle.ts b/src/server/index/spend-ledger-lifecycle.ts index d7250642470..ddcc70a6a3b 100644 --- a/src/server/index/spend-ledger-lifecycle.ts +++ b/src/server/index/spend-ledger-lifecycle.ts @@ -8,11 +8,27 @@ import { spendPolicyFromConfig, } from "../../lib/spend-reservation-ledger"; +const failedStartRollbacks = new WeakMap>(); + +export function recordFailedStartRollback(error: unknown, rollback: Promise): void { + if ((typeof error === "object" && error !== null) || typeof error === "function") { + failedStartRollbacks.set(error, rollback); + } +} + +/** Keep an outer ownership lease until a synchronous start failure has closed every listener. */ +export function waitForFailedStartRollback(error: unknown): Promise { + if ((typeof error === "object" && error !== null) || typeof error === "function") { + return failedStartRollbacks.get(error) ?? Promise.resolve(); + } + return Promise.resolve(); +} + export interface SpendLedgerServerLifecycle { configure(spend: OcxSpendConfig | undefined): void; track }>(server: T): T; release(): void; - releaseAfterFailedStart(): void; + releaseAfterFailedStart(): Promise; } /** Acquire before config loading so every later startup failure has one rollback owner. */ @@ -39,28 +55,38 @@ export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedge return server; }, release, - releaseAfterFailedStart(): void { + releaseAfterFailedStart(): Promise { // Every listener that came up is stopped, newest first, and the lease is held until those // stops have actually SETTLED. Bun's Server.stop(true) returns a promise that resolves // once connections are closed, so discarding it handed the state directory back while a // listener could still be serving, which is the one thing single-writer ownership exists // to prevent. // - // This stays synchronous and returns void on purpose: startServer must not become async, - // so the wait is a continuation rather than an await. Rollback failures are contained - // because the startup error that brought us here is the one worth reporting. + // Listener shutdown starts synchronously, while the returned promise lets a caller that + // owns a broader mutation lease keep it until every close has settled. startServer itself + // remains synchronous. Rollback failures are reported beside the startup error by the + // outer ownership transaction, without claiming the listener is gone. const settling: Promise[] = []; + const failures: unknown[] = []; for (const stop of failedStartStops.splice(0).reverse()) { try { const pending = stop(); if (pending !== undefined) settling.push(Promise.resolve(pending)); - } catch { /* a rollback failure must not replace the startup error that caused it */ } + } catch (failure) { failures.push(failure); } } const finish = (): void => { try { release(); } catch { /* same: the startup error is the one that matters */ } }; - if (settling.length === 0) { finish(); return; } - void Promise.allSettled(settling).then(finish); + if (settling.length === 0) { + if (failures.length > 0) return Promise.reject(new AggregateError(failures, "failed-start listener rollback was uncertain")); + finish(); + return Promise.resolve(); + } + return Promise.allSettled(settling).then(results => { + for (const result of results) if (result.status === "rejected") failures.push(result.reason); + if (failures.length > 0) throw new AggregateError(failures, "failed-start listener rollback was uncertain"); + finish(); + }); }, }; } diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index c3ab23e641b..fc757cc0670 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -357,12 +357,12 @@ export function getServerListenPort(): number | undefined { * caller sees the same result before a replacement binds the port. Swallowing it would let * `drainAndShutdown` report success while a socket is still held. * - * `always` runs after the listeners regardless of their outcome, and its own failure joins the - * reported set rather than replacing it. + * `always` runs after the listeners regardless of their outcome and receives whether every + * listener stop succeeded. Its own failure joins the reported set rather than replacing it. */ export async function runListenerShutdown( steps: Array<() => Promise>, - always: () => Promise, + always: (listenersStopped: boolean) => Promise, ): Promise { const failures: unknown[] = []; // Close admission and start connection-owner cleanup before waiting for any drain. @@ -372,7 +372,7 @@ export async function runListenerShutdown( if (result.status === "rejected") failures.push(result.reason); } try { - await always(); + await always(failures.length === 0); } catch (error) { failures.push(error); } diff --git a/src/service/ownership-mutation-lease.d.mts b/src/service/ownership-mutation-lease.d.mts index 574df756c6f..48d293263cd 100644 --- a/src/service/ownership-mutation-lease.d.mts +++ b/src/service/ownership-mutation-lease.d.mts @@ -11,6 +11,15 @@ export interface OwnershipMutationLease { readonly token: string; release(): voi export declare const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV: "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; +export declare function ownershipMutationLeaseChildEnvironment( + environment: NodeJS.ProcessEnv, + token: string, +): NodeJS.ProcessEnv; + +export declare function unprivilegedOwnershipMutationEnvironment( + environment: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv; + export declare function acquireOwnershipMutationLease( statePaths: readonly string[], options?: OwnershipMutationLeaseOptions, diff --git a/src/service/ownership-mutation-lease.mjs b/src/service/ownership-mutation-lease.mjs index 3bb9919d227..008d4e14f27 100644 --- a/src/service/ownership-mutation-lease.mjs +++ b/src/service/ownership-mutation-lease.mjs @@ -24,6 +24,16 @@ const delegatedTokens = new Map(); const sleeper = new Int32Array(new SharedArrayBuffer(4)); export const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV = "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; +export function ownershipMutationLeaseChildEnvironment(environment, token) { + return { ...environment, [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: token }; +} + +export function unprivilegedOwnershipMutationEnvironment(environment) { + const child = { ...environment }; + delete child[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + return child; +} + function sleep(ms) { Atomics.wait(sleeper, 0, 0, ms); } function processAlive(pid) { try { process.kill(pid, 0); return true; } diff --git a/src/update/runtime-ownership.d.mts b/src/update/runtime-ownership.d.mts index 12762895238..754c3499a4d 100644 --- a/src/update/runtime-ownership.d.mts +++ b/src/update/runtime-ownership.d.mts @@ -8,3 +8,33 @@ export declare function planUpdateRuntimeHandling(input: { mayRestoreService: boolean; notice: string | null; }; + +export declare function planStoppedRuntimeRecovery(input: { + stopAttempted: boolean; + ownership: { owner: string; installId: string; consentGeneration: number } | null; + ownershipUnknown?: boolean; + sameOwner: boolean; + liveness: "live" | "dead" | "unknown"; + serviceInstalled: boolean; + launcherUsable: boolean; + hadRuntimeState: boolean; +}): { + action: "none" | "manual" | "service" | "direct"; + reason: string; +}; + +type RuntimeTarget = { port: number; hostname: string }; +type RuntimeLiveness = "live" | "dead" | "unknown"; + +export declare function inspectPackageRuntimeLiveness(input: { + capturedTarget: RuntimeTarget; + readCurrentTarget(): + | { kind: "target"; target: RuntimeTarget } + | { kind: "absent" } + | { kind: "unknown" }; + probe(target: RuntimeTarget): RuntimeLiveness; +}): { + current: RuntimeLiveness | "absent"; + captured: RuntimeLiveness; + overall: RuntimeLiveness; +}; diff --git a/src/update/runtime-ownership.mjs b/src/update/runtime-ownership.mjs index 432f223bc73..c9eb52e5df8 100644 --- a/src/update/runtime-ownership.mjs +++ b/src/update/runtime-ownership.mjs @@ -73,3 +73,50 @@ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, notice: null, }; } + +/** Decide recovery after this updater already stopped the prior CLI-owned runtime. */ +export function planStoppedRuntimeRecovery({ + stopAttempted, + ownership, + ownershipUnknown = false, + sameOwner, + liveness, + serviceInstalled, + launcherUsable, + hadRuntimeState, +}) { + if (!stopAttempted) return { action: "none", reason: "not-stopped" }; + if (ownershipUnknown) return { action: "manual", reason: "ownership-unknown" }; + if (!sameOwner || (ownership && ownership.owner !== "cli")) { + return { action: "none", reason: "ownership-transferred" }; + } + if (liveness !== "dead") return { action: "manual", reason: `runtime-${liveness}` }; + if (!launcherUsable) return { action: "manual", reason: "launcher-unavailable" }; + if (serviceInstalled) return { action: "service", reason: "same-cli-owner" }; + if (hadRuntimeState) return { action: "direct", reason: "same-cli-owner" }; + return { action: "none", reason: "nothing-to-restore" }; +} + +/** + * Re-read the current package runtime before probing. The result keeps an absent current + * record distinct from a dead captured endpoint while still projecting one fail-closed + * liveness verdict for replacement and recovery decisions. + */ +export function inspectPackageRuntimeLiveness({ capturedTarget, readCurrentTarget, probe }) { + const currentTarget = readCurrentTarget(); + const observations = new Map(); + const inspect = target => { + const key = `${target.hostname}:${target.port}`; + if (!observations.has(key)) observations.set(key, probe(target)); + return observations.get(key); + }; + // Probe the fresh record first. It is the address a replacement runtime may have + // published while the updater was waiting on the ownership lease. + const current = currentTarget.kind === "target" ? inspect(currentTarget.target) : currentTarget.kind; + const captured = inspect(capturedTarget); + const verdicts = current === "absent" ? [captured] : [current, captured]; + const overall = verdicts.includes("live") + ? "live" + : verdicts.includes("unknown") ? "unknown" : "dead"; + return { current, captured, overall }; +} diff --git a/structure/runtime.md b/structure/runtime.md index 25094f7e101..384b6b394e6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -577,10 +577,15 @@ three independent authorities: package replacement, runtime stop and service res Unknown and desktop ownership deny all three because a claim alone does not prove that the live process is detached from the npm package; CLI ownership permits the ordinary stop-first flow. Both package updaters use `src/service/install-state-contract.mjs`, backed by the single -`state-record.mjs` parser and authority selector. A shared mutation lease covers the final subject/liveness -recheck through package replacement, and service install/start plus every state mutation take -the same lease, so the stop decision cannot go stale under a cooperating manager. Dashboard -restart keeps that lease through port reclaim and delegates its token to the repair child; -direct start takes it again for the final liveness check through successful bind. +`state-record.mjs` parser and authority selector. One mutation lease covers the fresh stop +authorization, the stop child, the current runtime-record re-read and package replacement. +The updater never treats the pre-stop address as proof that this installation is idle; +an unreadable current record is unknown, and a valid address is probed even when its recorded +PID is gone. Lease delegation is passed only to stop and recovery children, never package +manager children. A replacement refusal passes through owner-aware recovery: only the same CLI +owner revives the stopped runtime; foreign ownership stays transferred and unknown ownership +remains a reported recovery requirement. Dashboard restart delegates the lease token to its repair child. Direct +start holds the same lease through bind plus PID and runtime-address publication. If listener +rollback cannot prove the socket closed, the process retains its lease until exit. The registration is never deleted; `ocx service install` releases the marker only after the registration succeeds. diff --git a/tests/cli/cli-catalog-prewarm.test.ts b/tests/cli/cli-catalog-prewarm.test.ts index e2c1af3e1fa..2c9cdd3aa67 100644 --- a/tests/cli/cli-catalog-prewarm.test.ts +++ b/tests/cli/cli-catalog-prewarm.test.ts @@ -53,18 +53,18 @@ describe("catalog prewarm on handleStart bind", () => { } }); - test("handleStart schedules catalog prewarm immediately after a successful bind", async () => { + test("handleStart schedules catalog prewarm after ownership publication", async () => { const cli = (await readText("src/cli/index.ts")).replace(/\r\n/g, "\n"); - const bindIdx = cli.indexOf("server = startServer(port"); + const transactionIdx = cli.indexOf("boundStart = await bindAndPublishStartOwnership({"); + const publishedIdx = cli.indexOf("const { server, serverModule, port, readinessGate, config } = boundStart", transactionIdx); const prewarmIdx = cli.indexOf("scheduleCatalogPrewarm()"); - const breakIdx = cli.indexOf("\n break;", bindIdx); + const guardianIdx = cli.indexOf("const guardian = startTokenGuardian()", prewarmIdx); expect(cli).toContain('from "./catalog-prewarm"'); - expect(bindIdx).toBeGreaterThan(-1); - expect(prewarmIdx).toBeGreaterThan(bindIdx); - expect(breakIdx).toBeGreaterThan(prewarmIdx); - // Must stay inside the successful-bind try path, not only on a later sync. - expect(cli.slice(bindIdx, breakIdx)).toContain("scheduleCatalogPrewarm()"); + expect(transactionIdx).toBeGreaterThan(-1); + expect(publishedIdx).toBeGreaterThan(transactionIdx); + expect(prewarmIdx).toBeGreaterThan(publishedIdx); + expect(guardianIdx).toBeGreaterThan(prewarmIdx); expect(cli).not.toContain('void import("../codex/catalog").then(({ gatherRoutedModels })'); }); }); diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 7cf4877feae..83502242139 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -407,13 +407,12 @@ describe("a busy preferred port never becomes a second proxy (#5004)", () => { // One 750ms probe is what produced the duplicate; the guard spends the larger budget. expect(fn).toContain("START_OWNERSHIP_LIVENESS"); - // Both refusals end the process, and the refusal a user sees is the one they already - // know from the owner path. - expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,400}?process\.exit\(1\)/); + // Both refusals preserve the exit code through the caller's lease-cleanup boundary. + expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,400}?StartCommandExit\(1\)/); expect(fn).toContain("Use 'ocx stop' first."); - expect(fn).toMatch(/decision === "refuse-unidentified-holder"[\s\S]{0,700}?process\.exit\(1\)/); + expect(fn).toMatch(/decision === "refuse-unidentified-holder"[\s\S]{0,700}?StartCommandExit\(1\)/); // The wrapper's `if %ERRORLEVEL% NEQ 0` loop still terminates on a served port. - expect(fn).toMatch(/decision === "service-stay-out"[\s\S]{0,500}?process\.exit\(0\)/); + expect(fn).toMatch(/decision === "service-stay-out"[\s\S]{0,500}?StartCommandExit\(0\)/); }); test("the pre-bind owner probe spends the same budget before it deletes state", () => { diff --git a/tests/cli/cli-ready.test.ts b/tests/cli/cli-ready.test.ts index 197c0ae276f..c214ecde156 100644 --- a/tests/cli/cli-ready.test.ts +++ b/tests/cli/cli-ready.test.ts @@ -850,19 +850,19 @@ describe("runReady production findLiveProxy deadline wiring (source-level)", () describe("handleStart OCX_SERVICE exit guard (source-level)", () => { const cliSource = readFileSync(repoPath("src/cli/index.ts"), "utf8"); - test("an already-live proxy exits 0 in OCX_SERVICE context", () => { + test("an already-live proxy preserves the service/refusal exit codes without bypassing cleanup", () => { // The `OCX_SERVICE === "1"` comparison moved into `decideStartWithLiveOwner` // (src/cli/dispatch.ts), where the sentinel semantics are asserted at runtime // across the whole matrix (tests/cli/cli-dispatch.test.ts). This oracle pins the - // exits that the decision routes to: stay-out exits 0, the conflict exits 1. + // typed exits that the decision routes to: stay-out returns 0, the conflict returns 1. expect(cliSource).toMatch(/decideStartWithLiveOwner\(\{/); - // Anchored at the owner branch. `chooseListenPort` carries its own stay-out/refusal pair - // for the busy-port guard (#5004) and it sits EARLIER in the file, so an unanchored match - // would quietly move to that one and stop asserting anything about this branch. - const ownerBranch = cliSource.slice(cliSource.indexOf("decideStartWithLiveOwner({")); - const stayOut = ownerBranch.match(/decision === "service-stay-out"[\s\S]{0,800}?process\.exit\(0\)/); - expect(stayOut, "the service stay-out decision must exit 0 when the port is already served").not.toBeNull(); - const nonService = ownerBranch.match(/Proxy already running[\s\S]{0,300}?process\.exit\(1\)/); + // Anchor after the lease transaction begins. The earlier preflight has the same decision + // pair but does not need a typed exit because it owns no lease yet. + const transaction = cliSource.slice(cliSource.indexOf("bindAndPublishStartOwnership({")); + const ownerBranch = transaction.slice(transaction.indexOf("decideStartWithLiveOwner({")); + const stayOut = ownerBranch.match(/decision === "service-stay-out"[\s\S]{0,800}?StartCommandExit\(0\)/); + expect(stayOut, "the service stay-out decision must return 0 when the port is already served").not.toBeNull(); + const nonService = ownerBranch.match(/decision === "refuse"[\s\S]{0,500}?StartCommandExit\(1\)/); expect(nonService, "non-service refusal keeps the exit 1 conflict error").not.toBeNull(); }); diff --git a/tests/cli/start-ownership-publication.test.ts b/tests/cli/start-ownership-publication.test.ts new file mode 100644 index 00000000000..23a3c825987 --- /dev/null +++ b/tests/cli/start-ownership-publication.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + bindAndPublishStartOwnership, + StartOwnershipRollbackUncertainError, +} from "../../src/cli/start-ownership-publication"; + +function fixture(options: { + failPid?: boolean; + failRuntime?: boolean; + failStop?: boolean; + failRemoveRuntime?: boolean; + failRemovePid?: boolean; +} = {}) { + const events: string[] = []; + const deps = { + acquireLease: () => ({ release: () => { events.push("release"); } }), + bind: async () => { events.push("bind"); return { id: 1 }; }, + writePid: () => { + events.push("pid"); + if (options.failPid) throw new Error("pid write failed"); + }, + writeRuntime: () => { + events.push("runtime"); + if (options.failRuntime) throw new Error("runtime write failed"); + }, + stopBound: async () => { + events.push("stop"); + if (options.failStop) throw new Error("stop failed"); + }, + removeRuntime: () => { + events.push("remove-runtime"); + if (options.failRemoveRuntime) throw new Error("runtime cleanup failed"); + }, + removePid: () => { + events.push("remove-pid"); + if (options.failRemovePid) throw new Error("pid cleanup failed"); + }, + }; + return { events, deps }; +} + +describe("start ownership publication", () => { + test("success releases only after bind and both records", async () => { + const { events, deps } = fixture(); + await bindAndPublishStartOwnership(deps); + expect(events).toEqual(["bind", "pid", "runtime", "release"]); + }); + + test("a bind refusal releases without publishing or rollback", async () => { + const { events, deps } = fixture(); + deps.bind = async () => { events.push("bind-refused"); throw new Error("refused"); }; + await expect(bindAndPublishStartOwnership(deps)).rejects.toThrow("refused"); + expect(events).toEqual(["bind-refused", "release"]); + }); + + for (const failure of ["pid", "runtime"] as const) { + test(`${failure} publication failure stops and cleans before release`, async () => { + const { events, deps } = fixture({ + failPid: failure === "pid", + failRuntime: failure === "runtime", + }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toThrow(`${failure} write failed`); + expect(events).toEqual(failure === "pid" + ? ["bind", "pid", "stop", "remove-runtime", "remove-pid", "release"] + : ["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid", "release"]); + }); + } + + test("listener rollback uncertainty cleans records but retains the lease", async () => { + const { events, deps } = fixture({ + failRuntime: true, + failStop: true, + }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toBeInstanceOf(StartOwnershipRollbackUncertainError); + expect(events).toEqual(["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid"]); + }); + + test("cleanup failures still attempt both records and release after the listener stopped", async () => { + const { events, deps } = fixture({ failRuntime: true, failRemoveRuntime: true, failRemovePid: true }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toBeInstanceOf(AggregateError); + expect(events).toEqual(["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid", "release"]); + }); +}); diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts index 797b2192e1b..4772e1a6c95 100644 --- a/tests/clients/desktop-install-identity.test.ts +++ b/tests/clients/desktop-install-identity.test.ts @@ -1,12 +1,13 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; +import { parseServiceOwnershipRecord } from "../../src/service/state-record.mjs"; import { repoPath } from "../helpers/repo-root"; /** * The desktop app's half of the runtime-ownership claim. * * The claim lives in the shared service install state, which core owns across two files: the - * validation that decides what a record may say is in `src/service/install-state-contract.mjs`, + * validation that decides what a record may say is in `src/service/state-record.mjs`, * and the types, the three answers a read can give and `ownershipGrantedTo` — the comparison an * installation applies to its own locally stored install id — are in `src/service/state.ts`. The * shell holds the other half, an id of its own to compare against, and mirrors the rule rather @@ -21,7 +22,6 @@ const IDENTITY = repoPath(`${SHELL}/identity.rs`); const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); const STARTUP = repoPath(`${SHELL}/startup.rs`); const STATE = repoPath("src/service/state.ts"); -const CONTRACT = repoPath("src/service/install-state-contract.mjs"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); @@ -31,7 +31,6 @@ describe("desktop install identity", () => { const identity = code(IDENTITY); const ownership = code(OWNERSHIP); const state = code(STATE); - const contract = code(CONTRACT); test("the installation's id is minted once and never rewritten", () => { // Exclusive, because two launches racing to mint would answer to two ids, and the second one @@ -55,10 +54,13 @@ describe("desktop install identity", () => { }); test("the owner values are the ones the record accepts", () => { - // Both halves of core's answer are read. The runtime rejection is what a record on disk meets, - // and the exported type is what every caller is compiled against; a parse that accepted a - // third owner and a type that forbade it would disagree exactly where a takeover happens. - expect(contract).toContain('value.owner !== "cli" && value.owner !== "desktop"'); + // Exercise the parser a record on disk actually meets, while also pinning the exported type + // every caller compiles against. If runtime acceptance and the type diverge, this takeover + // boundary fails at review instead of after an installation has claimed the runtime. + const claim = { installId: "install-a", consentGeneration: 1 }; + expect(parseServiceOwnershipRecord({ ...claim, owner: "cli" })).toEqual({ ...claim, owner: "cli" }); + expect(parseServiceOwnershipRecord({ ...claim, owner: "desktop" })).toEqual({ ...claim, owner: "desktop" }); + expect(parseServiceOwnershipRecord({ ...claim, owner: "another-owner" })).toBeNull(); expect(state).toContain('export type ServiceOwner = "cli" | "desktop"'); expect(ownership).toContain('#[serde(rename_all = "lowercase")]'); expect(ownership).toContain(" Cli,"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d08b15a92d9..38e3a733c72 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -15,6 +15,7 @@ "provider-egress-fetch.test.ts": "responses", "provider-egress-management-validation.test.ts": "server", "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 29a10a44135..150c1da8cbe 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -813,11 +813,11 @@ describe("composite listener shutdown", () => { async () => { ran.push("primary"); throw failure; }, async () => { ran.push("loopback"); }, ], - async () => { ran.push("lifecycle"); }, + async listenersStopped => { ran.push(`lifecycle:${listenersStopped}`); }, )).rejects.toBe(failure); // The whole point: a rejected primary stop must not strand the loopback socket or skip // the native lifecycle release. - expect(ran).toEqual(["primary", "loopback", "lifecycle"]); + expect(ran).toEqual(["primary", "loopback", "lifecycle:false"]); }); test("two failures are reported together rather than one hiding the other", async () => { @@ -848,8 +848,10 @@ describe("composite listener shutdown", () => { }); test("an all-clear shutdown resolves", async () => { - await expect(runListenerShutdown([async () => {}, async () => {}], async () => {})) + let listenersStopped: boolean | undefined; + await expect(runListenerShutdown([async () => {}, async () => {}], async stopped => { listenersStopped = stopped; })) .resolves.toBeUndefined(); + expect(listenersStopped).toBe(true); }); }); diff --git a/tests/server/spend-ledger-lifecycle.test.ts b/tests/server/spend-ledger-lifecycle.test.ts index 17154d46b8f..85607f05376 100644 --- a/tests/server/spend-ledger-lifecycle.test.ts +++ b/tests/server/spend-ledger-lifecycle.test.ts @@ -64,7 +64,7 @@ test("a failed start keeps the lease until every listener has actually stopped", lifecycle.track(second.server); expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); - lifecycle.releaseAfterFailedStart(); + const rollback = lifecycle.releaseAfterFailedStart(); // Newest first, and both asked before anything is awaited. expect(stopOrder).toEqual(["second", "first"]); await drainContinuations(); @@ -76,7 +76,7 @@ test("a failed start keeps the lease until every listener has actually stopped", expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); second.settle(); - await drainContinuations(); + await rollback; expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); }); @@ -87,7 +87,7 @@ test("a listener whose stop rejects still stops the rest and still returns the d lifecycle.track(healthy.server); lifecycle.track(failing.server); - lifecycle.releaseAfterFailedStart(); + const rollback = lifecycle.releaseAfterFailedStart(); expect(stopOrder).toEqual(["failing", "healthy"]); failing.settle(); @@ -96,6 +96,10 @@ test("a listener whose stop rejects still stops the rest and still returns the d expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); healthy.settle(); - await drainContinuations(); + await expect(rollback).rejects.toThrow("failed-start listener rollback was uncertain"); + expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); + // Explicit test cleanup. Production keeps this owner until process exit because the + // rejected stop cannot prove the listener released its socket. + lifecycle.release(); expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); }); diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index 4cac1931916..94a1aa140b4 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -36,6 +36,11 @@ import { type ServiceStateSwapDeps, } from "../../src/service/state"; import { assessServiceTakeoverCompatibility, type ManagingCliObservation } from "../../src/service/ownership-compatibility"; +import { + OWNERSHIP_MUTATION_LEASE_TOKEN_ENV, + ownershipMutationLeaseChildEnvironment, + unprivilegedOwnershipMutationEnvironment, +} from "../../src/service/ownership-mutation-lease.mjs"; let home: TempHome; /** @@ -522,6 +527,15 @@ describe("the generation cannot be reused", () => { }); describe("the anchor lock", () => { + test("delegation is granted only to selected child environments", () => { + const parent = { KEEP: "yes", [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: "stale" }; + const delegated = ownershipMutationLeaseChildEnvironment(parent, "current"); + const unprivileged = unprivilegedOwnershipMutationEnvironment(parent); + expect(delegated).toEqual({ KEEP: "yes", [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: "current" }); + expect(unprivileged).toEqual({ KEEP: "yes" }); + expect(parent[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]).toBe("stale"); + }); + test("a live update lease blocks ownership mutation before the state lock is touched", () => { const leasePath = serviceStatePath() + ".mutation.lock"; const processInstance = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index 963be923f09..004266a7df4 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -11,7 +11,11 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { repoPath } from "../helpers/repo-root"; -import { planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; +import { + inspectPackageRuntimeLiveness, + planStoppedRuntimeRecovery, + planUpdateRuntimeHandling, +} from "../../src/update/runtime-ownership.mjs"; import { parseInstallStateRecord, selectAuthoritativeServiceState } from "../../src/service/install-state-contract.mjs"; describe("the runtime-ownership veto", () => { @@ -47,6 +51,81 @@ describe("the runtime-ownership veto", () => { }); }); +describe("stopped runtime recovery authority", () => { + const base = { + stopAttempted: true, + ownership: null, + ownershipUnknown: false, + sameOwner: true, + liveness: "dead" as const, + serviceInstalled: false, + launcherUsable: true, + hadRuntimeState: true, + }; + + test("only the same readable CLI owner with dead endpoints can restart", () => { + expect(planStoppedRuntimeRecovery(base)).toEqual({ action: "direct", reason: "same-cli-owner" }); + expect(planStoppedRuntimeRecovery({ ...base, serviceInstalled: true })).toEqual({ + action: "service", reason: "same-cli-owner", + }); + }); + + test("foreign, unknown and live outcomes never revive the stopped runtime", () => { + expect(planStoppedRuntimeRecovery({ ...base, sameOwner: false })).toEqual({ + action: "none", reason: "ownership-transferred", + }); + expect(planStoppedRuntimeRecovery({ ...base, ownershipUnknown: true })).toEqual({ + action: "manual", reason: "ownership-unknown", + }); + for (const liveness of ["live", "unknown"] as const) { + expect(planStoppedRuntimeRecovery({ ...base, liveness })).toEqual({ + action: "manual", reason: `runtime-${liveness}`, + }); + } + }); +}); + +describe("replacement runtime inspection", () => { + const capturedTarget = { hostname: "127.0.0.1", port: 10100 }; + + test("the fresh runtime record is read and probed before the captured stop target", () => { + const currentTarget = { hostname: "127.0.0.1", port: 10200 }; + const events: string[] = []; + const result = inspectPackageRuntimeLiveness({ + capturedTarget, + readCurrentTarget: () => { events.push("read-current"); return { kind: "target", target: currentTarget }; }, + probe: target => { + events.push(`probe:${target.port}`); + return target === currentTarget ? "live" : "dead"; + }, + }); + expect(events).toEqual(["read-current", `probe:${currentTarget.port}`, `probe:${capturedTarget.port}`]); + expect(result).toEqual({ current: "live", captured: "dead", overall: "live" }); + }); + + test("an absent current record stays distinct from a dead captured endpoint", () => { + const events: string[] = []; + const result = inspectPackageRuntimeLiveness({ + capturedTarget, + readCurrentTarget: () => { events.push("read-current"); return { kind: "absent" }; }, + probe: target => { events.push(`probe:${target.port}`); return "dead"; }, + }); + expect(events).toEqual(["read-current", `probe:${capturedTarget.port}`]); + expect(result).toEqual({ current: "absent", captured: "dead", overall: "dead" }); + }); + + test("an unreadable current record fails closed even when the captured endpoint is dead", () => { + const events: string[] = []; + const result = inspectPackageRuntimeLiveness({ + capturedTarget, + readCurrentTarget: () => { events.push("read-current"); return { kind: "unknown" }; }, + probe: target => { events.push(`probe:${target.port}`); return "dead"; }, + }); + expect(events).toEqual(["read-current", `probe:${capturedTarget.port}`]); + expect(result).toEqual({ current: "unknown", captured: "dead", overall: "unknown" }); + }); +}); + describe("the Node and Bun paths share one full-record authority", () => { const state = (revision: number, installId = "desktop-a") => ({ version: 2, codexHome: "/codex", opencodexHome: "/opencodex", backend: "scheduler", @@ -99,11 +178,11 @@ describe("both updaters consult the shared rule", () => { test("the npm launcher gates its stop, its refresh and its failure recovery", () => { expect(launcher).toContain("from \"../src/update/runtime-ownership.mjs\""); - expect(launcher).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(launcher).toContain("if (stopNeeded && !runtimePlan.mayStopRuntime)"); + expect(launcher).toContain("if (stopNeeded) {"); expect(launcher).toContain("if (!runtimePlan.mayReplacePackage)"); - // Nothing was stopped, so nothing is recovered: starting a proxy here would put a - // second one beside the runtime the app is managing. - expect(launcher).toContain("if (!recoveryPlan.mayStopRuntime) return;"); + expect(launcher).toContain("planStoppedRuntimeRecovery({"); + expect(launcher).toContain("if (postInstallPlan.mayRestoreService) {"); }); test("neither updater reimplements the decision", () => { @@ -123,7 +202,8 @@ describe("both updaters consult the shared rule", () => { expect(launcher).toContain("selectAuthoritativeServiceState("); expect(launcher).toContain("serviceStateFilesFor("); expect(launcher).not.toContain("parsed.ownership"); - expect(launcher).not.toContain("consentGeneration"); + const reader = launcher.slice(launcher.indexOf("const readOwnership = () =>"), launcher.indexOf("const ownershipIdentity =")); + expect(reader).not.toContain("consentGeneration"); // The authoritative reader delegates to the same module rather than keeping a twin. const state = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); expect(state).toContain('from "./install-state-contract.mjs"'); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 8b6d0777970..f2e1ebc809d 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -786,7 +786,9 @@ esac // mid-deferral the service, pid and runtime records can all be absent while shared // client config still points at a proxy that is gone (#3008). expect(updateSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); - expect(launcherSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(launcherSource).toContain("const stopNeeded = serviceWasInstalled || hasRuntimeState || hasPendingTeardown"); + expect(launcherSource).toContain("if (stopNeeded && !runtimePlan.mayStopRuntime)"); + expect(launcherSource).toContain("if (stopNeeded) {"); // The rule now lives in the shared post-stop decision both lanes import (#3008): a // history-only stop proceeds, every other nonzero status and any surviving runtime // state aborts. Pinned by tests/update/update-stop-classification.test.ts. @@ -794,6 +796,41 @@ esac expect(launcherSource).toContain("hasRuntimeState: stillHasRuntimeState"); }); + test("the Node updater holds one authority from stop permission through replacement", () => { + const leaseAt = launcherSource.indexOf("const updateLease = acquireOwnershipMutationLease("); + const lockedPlanAt = launcherSource.indexOf("const lockedPlan = planUpdateRuntimeHandling(", leaseAt); + const stopAt = launcherSource.indexOf('[launcher, "stop"]', lockedPlanAt); + const replacementAt = launcherSource.indexOf("const replacementOwnership = readOwnership()", stopAt); + const releaseAt = launcherSource.indexOf("releaseUpdateLease()", replacementAt); + expect(leaseAt).toBeGreaterThan(-1); + expect(lockedPlanAt).toBeGreaterThan(leaseAt); + expect(stopAt).toBeGreaterThan(lockedPlanAt); + expect(replacementAt).toBeGreaterThan(stopAt); + expect(releaseAt).toBeGreaterThan(replacementAt); + const stopEnvAt = launcherSource.indexOf("env: mutationChildEnvironment()", stopAt); + expect(stopEnvAt).toBeGreaterThan(stopAt); + expect(stopEnvAt).toBeLessThan(replacementAt); + expect(launcherSource.slice(lockedPlanAt, stopAt)).toContain("!runtimePlan.mayStopRuntime"); + const packageReplacement = launcherSource.slice(stopAt, launcherSource.indexOf("const postInstallPlan", stopAt)); + expect(packageReplacement.match(/unprivilegedOwnershipMutationEnvironment/g)).toHaveLength(2); + }); + + test("replacement refusal reaches owner-aware recovery before releasing authority", () => { + const refusalAt = launcherSource.indexOf("replacementOwnership.subjectToken !== initialOwnership.subjectToken"); + const recoverAt = launcherSource.indexOf('recoverStoppedRuntimeAfterFailure("replacement was refused")', refusalAt); + const releaseAt = launcherSource.indexOf("releaseUpdateLease()", recoverAt); + expect(refusalAt).toBeGreaterThan(-1); + expect(recoverAt).toBeGreaterThan(refusalAt); + expect(releaseAt).toBeGreaterThan(recoverAt); + const recovery = launcherSource.slice( + launcherSource.indexOf("function recoverStoppedRuntimeAfterFailure("), + launcherSource.indexOf("const hasPendingTeardown", launcherSource.indexOf("function recoverStoppedRuntimeAfterFailure(")), + ); + expect(recovery).toContain("planStoppedRuntimeRecovery"); + expect(recovery).toContain("sameOwner:"); + expect(recovery).toContain("currentPackageRuntimeLiveness()"); + }); + test("GUI worker update children use pipe stdio so background updates do not open consoles", () => { expect(updateSource).toContain("function updateChildStdio()"); expect(updateSource).toContain('process.env.OCX_SERVICE === "1"'); From 52acf81d86eb2da0fc3de259c94149a468e3e8d5 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:23:38 +0900 Subject: [PATCH 86/87] fix(cli): probe endpoint liveness in-process so the standalone resolve does not fork execPath (#5418) * fix(cli): probe endpoint liveness in-process so the standalone resolve does not fork execPath Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli): prove absence on every loopback host and see refusals inside AggregateError Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: retrigger cross-platform run (macos 1/2 shard hit the 20-minute runner timeout) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli): keep a mixed aggregate out of the absence proof --------- Co-authored-by: jun Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/cli/index.ts | 17 ++-- src/cli/resolve.ts | 23 +++--- src/cli/status-probes.ts | 20 +---- src/cli/uninstall-plan.ts | 9 +++ src/server/proxy-liveness.ts | 75 +++++++++++++++++ tests/cli/cli-resolve.test.ts | 19 ++++- tests/cli/uninstall.test.ts | 6 +- tests/providers/xai/grok-lifecycle.test.ts | 2 +- tests/server/proxy-liveness.test.ts | 93 ++++++++++++++++++++++ 9 files changed, 224 insertions(+), 40 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index a9b411e9d32..88ab11392d7 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -67,7 +67,7 @@ import { quarantinePendingTeardown, } from "../config/pending-teardown"; import { collectStatus, deadProxyRoutingAdviceLines, detectMissingCodexCatalogPath, hubStatusLines, missingCodexCatalogLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; -import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; +import { endpointsToProve, everyEndpointProvenDownAsync, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; import { parseStartOptions, StartArgsError } from "./start-args"; @@ -85,7 +85,14 @@ import { SpendLedgerOwnerError } from "../lib/spend-ledger-owner"; import { redactUrlForLog } from "../lib/redact"; import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch"; import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; -import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; +import { + findLiveProxy, + probeEndpointLiveness, + probeHostname, + probePortOwner, + START_OWNERSHIP_LIVENESS, + type LiveProxy, +} from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; import { isApiAuthRequired } from "../server/auth-cors"; import { runReady, type ReadyArgs } from "./ready"; @@ -1021,8 +1028,7 @@ async function handleStopUnlocked() { // An obligation that cannot name its endpoint cannot be proven discharged. if (!endpoint) return false; try { - const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs"); - return probeProxyLiveness(endpoint.port, endpoint.hostname) === "dead"; + return await probeEndpointLiveness(endpoint) === "dead"; } catch { // A probe that could not run is not evidence of absence. return false; @@ -1436,11 +1442,10 @@ async function handleUninstall() { /** Definitive "nothing is answering" on the endpoint this home would serve. */ const proxyEndpointProvenDown = async (): Promise => { try { - const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs"); // Every candidate, not just the preferred one: a stale runtime record pointing at a // closed port would otherwise "prove" a live proxy on the configured port is gone. const endpoints = endpointsToProve(readRuntimePort(), loadConfig()); - return everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname)); + return await everyEndpointProvenDownAsync(endpoints, probeEndpointLiveness); } catch { return false; } diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts index a4dcc851645..87c3fa69174 100644 --- a/src/cli/resolve.ts +++ b/src/cli/resolve.ts @@ -37,9 +37,14 @@ import { readConfigDiagnostics, type ConfigDiagnostics } from "../config"; import { getConfigDir } from "../config/paths"; import { readRuntimePort } from "../config/process-state"; import { packageVersion } from "../lib/package-version"; -import { findLiveProxy, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; -import { endpointsToProve, everyEndpointProvenDown, type ProbeEndpoint } from "./uninstall-plan"; -import { probeProxyLiveness } from "../update/proxy-liveness-probe.mjs"; +import { + findLiveProxy, + probeEndpointLiveness, + START_OWNERSHIP_LIVENESS, + type EndpointLiveness, + type LiveProxy, +} from "../server/proxy-liveness"; +import { endpointsToProve, everyEndpointProvenDownAsync, type ProbeEndpoint } from "./uninstall-plan"; /** Wire version of the resolve document. Bump only on an incompatible shape change. */ export const RESOLVE_SCHEMA = "ocx-resolve/1"; @@ -103,8 +108,8 @@ export interface ResolveIo { findLive?: () => Promise; /** Runtime-port record reader; production default is readRuntimePort. */ readRuntime?: () => { port?: number; hostname?: string } | null; - /** Tri-state endpoint probe; production default is the updater's probeProxyLiveness. */ - probeEndpoint?: (endpoint: ProbeEndpoint) => "live" | "dead" | "unknown"; + /** Tri-state endpoint probe; production default runs in-process for compiled standalone binaries. */ + probeEndpoint?: (endpoint: ProbeEndpoint) => EndpointLiveness | Promise; cliVersion?: () => string; stdout?: { log: (s: string) => void }; stderr?: { error: (s: string) => void }; @@ -174,11 +179,7 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise const readDiagnostics = io.readDiagnostics ?? readConfigDiagnostics; const findLive = io.findLive ?? (() => findLiveProxy(START_OWNERSHIP_LIVENESS)); const readRuntime = io.readRuntime ?? readRuntimePort; - // The updater's tri-state probe takes (port, hostname) and is plain .mjs (untyped); - // adapt it to the endpoint-shaped seam here. Its own return vocabulary is the - // closed "live" | "dead" | "unknown" set. - const probeEndpoint = io.probeEndpoint - ?? ((endpoint: ProbeEndpoint) => probeProxyLiveness(endpoint.port, endpoint.hostname) as "live" | "dead" | "unknown"); + const probeEndpoint = io.probeEndpoint ?? probeEndpointLiveness; const cliVersion = io.cliVersion ?? packageVersion; const configHome = configDir(); let diagnostics: ConfigDiagnostics; @@ -212,7 +213,7 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise // authorise starting a second runtime. let provenDown = false; try { - provenDown = everyEndpointProvenDown(endpointsToProve(readRuntime(), diagnostics.config), probeEndpoint); + provenDown = await everyEndpointProvenDownAsync(endpointsToProve(readRuntime(), diagnostics.config), probeEndpoint); } catch { // A probe that cannot run is not evidence of absence. provenDown = false; diff --git a/src/cli/status-probes.ts b/src/cli/status-probes.ts index d3848c95366..d4196ba5cff 100644 --- a/src/cli/status-probes.ts +++ b/src/cli/status-probes.ts @@ -1,5 +1,5 @@ import { readPidFileValue, readRuntimePort } from "../config/process-state"; -import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; +import { isConnectionRefused, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import { directLocalHttpFetch } from "../server/direct-local-http"; import { isProcessAlive } from "../lib/process-control"; @@ -26,23 +26,7 @@ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): " : "unreachable"; } -/** - * "Nothing is listening" is narrower than "the probe failed". `unreachable` covers every - * non-abort failure, including a socket that was ACCEPTED and then reset — which is what - * an in-flight start looks like mid-bind. Only a connect-phase refusal proves the port is - * free, so this reads the underlying errno instead of the display string. - */ -export function isConnectionRefused(error: unknown): boolean { - for (let current: unknown = error, depth = 0; current instanceof Error && depth < 4; depth++) { - const code = (current as { code?: unknown }).code; - if (code === "ECONNREFUSED" || code === "ConnectionRefused") return true; - // Bun surfaces the refusal as a plain message on some platforms; the errno name is - // still the discriminator, not a substring of arbitrary prose. - if (typeof code === "string" && code.endsWith("ECONNREFUSED")) return true; - current = (current as { cause?: unknown }).cause; - } - return false; -} +export { isConnectionRefused } from "../server/proxy-liveness"; /** * A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes diff --git a/src/cli/uninstall-plan.ts b/src/cli/uninstall-plan.ts index 0e1df1cd2df..4bfb9d4a7a6 100644 --- a/src/cli/uninstall-plan.ts +++ b/src/cli/uninstall-plan.ts @@ -84,3 +84,12 @@ export function everyEndpointProvenDown( if (endpoints.length === 0) return false; return endpoints.every(e => probe(e) === "dead"); } + +export async function everyEndpointProvenDownAsync( + endpoints: readonly ProbeEndpoint[], + probe: (e: ProbeEndpoint) => Promise<"live" | "dead" | "unknown"> | "live" | "dead" | "unknown", +): Promise { + if (endpoints.length === 0) return false; + const results = await Promise.all(endpoints.map(e => probe(e))); + return results.every(result => result === "dead"); +} diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 594be867b02..98202e42d86 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -32,6 +32,8 @@ export interface HealthzIdentity { guiPairCapability?: unknown; } +export type EndpointLiveness = "live" | "dead" | "unknown"; + export interface LivenessIo { fetchFn?: typeof fetch; readPidFn?: () => number | null; @@ -85,6 +87,11 @@ export const START_OWNERSHIP_LIVENESS: Pick Promise; + export interface LiveProxy { pid: number | null; port: number; @@ -148,6 +155,74 @@ export function isOpencodexHealthz(body: HealthzIdentity | null): boolean { return body.status === "ok" && typeof body.version === "string" && typeof body.uptime === "number"; } +/** + * "Nothing is listening" is narrower than "the probe failed". Only a connect-phase refusal + * proves the endpoint is free; a timeout, reset, or other transport failure leaves the + * question open. + */ +export function isConnectionRefused(error: unknown): boolean { + const visit = (current: unknown, depth: number): boolean => { + if (depth >= 4) return false; + if (current === null || (typeof current !== "object" && typeof current !== "function")) return false; + const record = current as { code?: unknown; cause?: unknown; errors?: unknown }; + if (record.code === "ECONNREFUSED" || record.code === "ConnectionRefused") return true; + if (typeof record.code === "string" && record.code.endsWith("ECONNREFUSED")) return true; + if (Array.isArray(record.errors) && record.errors.length > 0) { + // One connect attempt fanned out over several addresses reports a single AggregateError. + // Only a unanimous refusal proves the endpoint is free: a bundle that mixes ECONNREFUSED + // with a timeout means one address answered nothing at all, and an address whose state is + // unreadable is unknown, not absence. Collapsing it to "refused" is how a second runtime + // gets started on a port that already has one. + return record.errors.every(error => visit(error, depth + 1)); + } + return visit(record.cause, depth + 1); + }; + return visit(error, 0); +} + +async function classifyHealthz( + url: string, + fetchFn: LivenessFetch, + timeoutMs: number, +): Promise { + try { + const response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (response.status !== 200) return "unknown"; + const body = (await response.json().catch(() => undefined)) as HealthzIdentity | null | undefined; + if (body === undefined) return "unknown"; + return isOpencodexHealthz(body) ? "live" : "dead"; + } catch (error) { + return isConnectionRefused(error) ? "dead" : "unknown"; + } +} + +/** + * Tri-state probe of one endpoint, the in-process counterpart of + * `src/update/proxy-liveness-probe.mjs`. Only a connect-phase refusal or a clean 200 that is + * not ours proves "dead"; a timeout, reset, non-200 or unreadable body leaves the question + * open. Loopback endpoints are checked on both IPv4 and IPv6 because a listener may bind only + * one family. Runs in-process because a compiled standalone binary cannot fork `execPath -e`. + */ +export async function probeEndpointLiveness( + endpoint: { port: number; hostname?: string }, + io: Pick = {}, +): Promise { + if (!Number.isFinite(endpoint.port) || endpoint.port <= 0 || endpoint.port > 65535) return "dead"; + const fetchFn = io.fetchFn ?? directLocalHttpFetch; + const timeoutMs = io.timeoutMs ?? 1500; + let sawUnknown = false; + for (const hostname of loopbackProbeHosts(endpoint.hostname)) { + const result = await classifyHealthz( + `http://${hostname}:${endpoint.port}/healthz`, + fetchFn, + timeoutMs, + ); + if (result === "live") return "live"; + if (result === "unknown") sawUnknown = true; + } + return sawUnknown ? "unknown" : "dead"; +} + /** Identity-checked /healthz probe; null when unreachable, non-OK, or not our proxy. */ export async function proxyIdentityAt( port: number, diff --git a/tests/cli/cli-resolve.test.ts b/tests/cli/cli-resolve.test.ts index 69fbac9764b..a7177ff241b 100644 --- a/tests/cli/cli-resolve.test.ts +++ b/tests/cli/cli-resolve.test.ts @@ -127,6 +127,21 @@ describe("runResolve", () => { expect(parsed.port.effective).toBe(RESOLVE_DEFAULT_PORT); }); + test("accepts async dead probes for every candidate endpoint", async () => { + const lines: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: {}, source: "default", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => ({ port: 10110, hostname: "127.0.0.1" }), + probeEndpoint: async () => "dead", + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + expect((JSON.parse(lines[0]!) as { liveness: { status: string } }).liveness.status).toBe("absent-proven"); + }); + test("an undecidable probe is unknown, and unknown is never answered as absent", async () => { // The launch decision keys on this verdict: a timed-out probe or a listener that // withholds /healthz must exit 1 rather than let the caller start a second runtime. @@ -152,8 +167,8 @@ describe("runResolve", () => { test("absence requires every endpoint dead, not just the configured one", async () => { // The runtime record can point at a live port while the configured port refuses; // answering from the configured port alone would shadow-start over the record. - // everyEndpointProvenDown short-circuits on the first non-dead answer: an unknown - // runtime endpoint defeats the proof without the configured one being probed. + // Every candidate is probed: an unknown runtime endpoint defeats the proof even when the + // configured endpoint is dead. const seen: string[] = []; const code = await runResolve({ json: true }, { configDir: () => "/h", diff --git a/tests/cli/uninstall.test.ts b/tests/cli/uninstall.test.ts index ea984f274fd..1b0e6f399b5 100644 --- a/tests/cli/uninstall.test.ts +++ b/tests/cli/uninstall.test.ts @@ -243,7 +243,7 @@ describe("uninstall gates shared teardown on a proven service stop", () => { }); }); test("proof covers every distinct endpoint, not just the preferred one", async () => { - const { endpointsToProve, everyEndpointProvenDown } = await import("../../src/cli/uninstall-plan"); + const { endpointsToProve, everyEndpointProvenDown, everyEndpointProvenDownAsync } = await import("../../src/cli/uninstall-plan"); // A stale runtime record pointing at a closed port, and the live proxy on the // configured one. Probing only the runtime candidate reports "dead" for a port nobody @@ -267,6 +267,8 @@ describe("uninstall gates shared teardown on a proven service stop", () => { expect(endpointsToProve(null, {})).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); // An empty set is not proof of anything. expect(everyEndpointProvenDown([], () => "dead")).toBe(false); + expect(await everyEndpointProvenDownAsync(endpoints, async () => "dead")).toBe(true); + expect(await everyEndpointProvenDownAsync([], async () => "dead")).toBe(false); // A nonsense runtime port is skipped rather than probed. expect(endpointsToProve({ port: 0 }, { port: 10100 })).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); }); @@ -284,7 +286,7 @@ describe("uninstall gates shared teardown on a proven service stop", () => { .toBeLessThan(windowStep.indexOf("observed.respawnWindowVerified = true;")); // And the proof itself asks every candidate. expect(fn).toContain("endpointsToProve(readRuntimePort(), loadConfig())"); - expect(fn).toContain("everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname))"); + expect(fn).toContain("everyEndpointProvenDownAsync(endpoints, probeEndpointLiveness)"); }); const safeTeardown: UninstallObservation = { diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index 4c930e57f5d..e6e42c80759 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -299,7 +299,7 @@ describe("Grok fence lifecycle wiring", () => { expect(noPidBranch).toContain("stopFailed = true;"); expect(noPidBranch).toContain("ownershipBlocked = true;"); const gateFn = sliceFn(CLI_SOURCE, "const abandonedTeardownIsSafeToFinish", "let stopFailed = false;"); - expect(gateFn).toContain('probeProxyLiveness(endpoint.port, endpoint.hostname) === "dead"'); + expect(gateFn).toContain('probeEndpointLiveness(endpoint) === "dead"'); expect(gateFn).toContain("return false;"); }); diff --git a/tests/server/proxy-liveness.test.ts b/tests/server/proxy-liveness.test.ts index d675f04c8b8..ec47ab6de97 100644 --- a/tests/server/proxy-liveness.test.ts +++ b/tests/server/proxy-liveness.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { createServer } from "node:net"; import { createReadinessGate, runStartupReadinessSync, @@ -7,7 +8,9 @@ import { DEFAULT_PROBE_TIMEOUT_MS, findLiveProxy, isOpencodexHealthz, + isConnectionRefused, loopbackProbeHosts, + probeEndpointLiveness, probeHostname, probePortOwner, probeReadiness, @@ -90,6 +93,96 @@ describe("probeHostname", () => { }); }); +describe("probeEndpointLiveness", () => { + test("classifies identity, foreign, non-200, refusal, timeout, and invalid ports", async () => { + const endpoint = { port: 10100, hostname: "127.0.0.1" }; + const fakeFetch = (body: unknown, status = 200) => (async () => healthz(body, status)) as typeof fetch; + expect(await probeEndpointLiveness(endpoint, { fetchFn: fakeFetch(OURS) })).toBe("live"); + expect(await probeEndpointLiveness(endpoint, { fetchFn: fakeFetch({ status: "ok" }) })).toBe("dead"); + expect(await probeEndpointLiveness(endpoint, { fetchFn: fakeFetch(OURS, 503) })).toBe("unknown"); + const refusedServer = createServer(); + await new Promise((resolve, reject) => { + refusedServer.once("error", reject); + refusedServer.listen(0, "127.0.0.1", () => resolve()); + }); + const refusedPort = (refusedServer.address() as { port: number }).port; + await new Promise((resolve, reject) => { + refusedServer.close(error => error ? reject(error) : resolve()); + }); + expect(await probeEndpointLiveness({ port: refusedPort, hostname: "127.0.0.1" })).toBe("dead"); + expect(await probeEndpointLiveness(endpoint, { + fetchFn: (async () => { throw new DOMException("aborted", "AbortError"); }) as typeof fetch, + })).toBe("unknown"); + expect(await probeEndpointLiveness(endpoint, { + fetchFn: (async () => { throw new Error("connection reset"); }) as typeof fetch, + })).toBe("unknown"); + expect(await probeEndpointLiveness({ port: 0 }, { fetchFn: fakeFetch(OURS) })).toBe("dead"); + }); + + test("checks both loopback families sequentially", async () => { + const seen: string[] = []; + const fetchFn = (async (url: string) => { + seen.push(url); + if (url.startsWith("http://127.0.0.1:10100")) { + throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + } + return healthz(OURS); + }) as typeof fetch; + expect(await probeEndpointLiveness({ port: 10100, hostname: "::" }, { fetchFn })).toBe("live"); + expect(seen).toEqual([ + "http://127.0.0.1:10100/healthz", + "http://[::1]:10100/healthz", + ]); + + seen.length = 0; + const refused = (async (url: string) => { + seen.push(url); + throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + }) as typeof fetch; + expect(await probeEndpointLiveness({ port: 10100, hostname: "::" }, { fetchFn: refused })).toBe("dead"); + expect(seen).toEqual([ + "http://127.0.0.1:10100/healthz", + "http://[::1]:10100/healthz", + ]); + + seen.length = 0; + const mixed = (async (url: string) => { + seen.push(url); + if (url.startsWith("http://127.0.0.1:10100")) { + throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + } + throw new DOMException("timed out", "TimeoutError"); + }) as typeof fetch; + expect(await probeEndpointLiveness({ port: 10100, hostname: "::" }, { fetchFn: mixed })).toBe("unknown"); + expect(seen).toEqual([ + "http://127.0.0.1:10100/healthz", + "http://[::1]:10100/healthz", + ]); + }); +}); + +describe("isConnectionRefused", () => { + test("recognizes aggregate socket refusals", () => { + const refused = Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + expect(isConnectionRefused(new AggregateError([refused]))).toBe(true); + expect(isConnectionRefused(new AggregateError([ + Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), + ]))).toBe(false); + }); + + test("a mixed aggregate is not proof of absence", () => { + // Happy-eyeballs style fan-out puts every address in one error. If one address refused and + // another never answered, the endpoint's state is unknown: the refusal speaks only for the + // address that produced it. + const refused = Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + const timedOut = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + expect(isConnectionRefused(new AggregateError([refused, timedOut]))).toBe(false); + expect(isConnectionRefused(new AggregateError([timedOut, refused]))).toBe(false); + expect(isConnectionRefused(new AggregateError([refused, refused]))).toBe(true); + expect(isConnectionRefused(new AggregateError([]))).toBe(false); + }); +}); + describe("proxyIdentityAt", () => { test("returns the reported pid for our proxy", async () => { const identity = await proxyIdentityAt(10100, {}, { fetchFn: (async () => healthz(OURS)) as typeof fetch }); From 59103942b2e9c1c858b2c19477c1cea2f0899a63 Mon Sep 17 00:00:00 2001 From: panyuanyuan Date: Mon, 21 Sep 2026 18:01:43 +0800 Subject: [PATCH 87/87] feat(kimi): register k3-256k in the picker and the price catalog Live verification 260921: k3-256k is the same K3 served under the explicit ceiling id - the same 988-token scaffold and identity answer as bare k3 on the same input. The subscription endpoint lists it alongside kimi-for-coding[-highspeed] and k3, but the opencodex picker and the expected-prices overlay only knew k3 and k3[1m], so usage logged under k3-256k showed as unestimable. - KIMI_CODING_K3_MODELS gains k3-256k, so the picker, context windows (262_144, the advertised ceiling), reasoning ladder and locked-parameter lists all derive it automatically. - expected-prices gains kimi/kimi-code entries at the same KIMI_K3 rate (input 3 / output 15 / cacheRead 0.3), sourced as verified-derived with the live probe note. - Parity and overlay-membership tests updated for the new id. Verified: provider-registry-parity + codex-catalog + usage-cost + model-rename-migration 539 pass, tsc clean. --- src/providers/registry/model-seeds.ts | 4 +++- src/usage/expected-prices.ts | 2 ++ tests/providers/provider-registry-parity.test.ts | 2 +- tests/usage/usage-cost.test.ts | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index 1d6884aabfb..c5b672581da 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -637,11 +637,13 @@ export const ALIBABA_TOKEN_PLAN_PRESERVE_REASONING = [ // entitlement tiers. Bare `k3` advertises the Moderato 256K ceiling; the local `[1m]` // alias advertises Allegretto's 1M ceiling and is stripped before the upstream request. // The separately billed Moonshot API uses `kimi-k3`. +// 260921: `k3-256k` is the same K3 served under the explicit ceiling id (verified live +// 260921: same 988-token scaffold and identity answer as bare `k3` on the same input). // Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html // https://www.kimi.com/code/docs/en/kimi-code/error-reference.html export const KIMI_K3_STANDARD_CONTEXT_WINDOW = 262_144; export const KIMI_K3_1M_CONTEXT_WINDOW = 1_048_576; -export const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]"]; +export const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]", "k3-256k"]; // 260921 Kimi K2.8: `kimi-for-coding` is the stable subscription alias Moonshot re-points // at each coding release. Live GET /coding/v1/models lists only kimi-for-coding[-highspeed], // k3, k3-256k — the k2.x ids are retired from the subscription endpoint. Since K2.8 Preview diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 351b6a00d27..5ab640d88a0 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -286,6 +286,7 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ // kimi-code = API key surface (expected list price, not actual billing). { provider: "kimi", modelId: "k3", cost4: KIMI_K3, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, { provider: "kimi", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: k3 (official docs: k3[1m] is the 1M-context compat notation for k3) ${KIMI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" }, + { provider: "kimi", modelId: "k3-256k", cost4: KIMI_K3, source: `derived: k3 (verified live 260921: same K3 served under the explicit ceiling id; identical prompt scaffold as bare k3) ${KIMI_PRICING}`, verifiedAt: "2026-09-21", status: "verified-derived" }, { provider: "kimi", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, { provider: "kimi", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, { provider: "kimi", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, @@ -298,6 +299,7 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ { provider: "moonshot", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, { provider: "kimi-code", modelId: "k3", cost4: KIMI_K3, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, { provider: "kimi-code", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: k3 ${KIMI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" }, + { provider: "kimi-code", modelId: "k3-256k", cost4: KIMI_K3, source: `derived: k3 (verified live 260921: same K3 served under the explicit ceiling id) ${KIMI_PRICING}`, verifiedAt: "2026-09-21", status: "verified-derived" }, { provider: "kimi-code", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, { provider: "kimi-code", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, { provider: "kimi-code", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index cf4d5b3bd78..70931a206c2 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -950,7 +950,7 @@ describe("provider registry parity", () => { // k3-256k). Seeding a retired id in a metadata list would re-arm the model-rename // migration on every boot (#5066); saved rows still naming one are repaired by // MODEL_RENAMES instead. - const codingModels = ["k3", "k3[1m]", "kimi-for-coding"]; + const codingModels = ["k3", "k3[1m]", "k3-256k", "kimi-for-coding"]; const parityLists = [ "noReasoningModels", "noTemperatureModels", diff --git a/tests/usage/usage-cost.test.ts b/tests/usage/usage-cost.test.ts index dffe93f2ab2..5381aff5f7a 100644 --- a/tests/usage/usage-cost.test.ts +++ b/tests/usage/usage-cost.test.ts @@ -328,8 +328,8 @@ describe("resolveMatchedPrice", () => { expect(resolveMatchedPrice("openrouter", "anthropic-claude-3.5-sonnet")).toBeNull(); }); - test("16. shipped overlay membership: 126 keys, including canonical Fable 5.1, Opus 5, OpenCode Go and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(126); + test("16. shipped overlay membership: 128 keys, including canonical Fable 5.1, Opus 5, OpenCode Go and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(128); expect(EXPECTED_PRICE_OVERLAYS.some(row => row.status === "unverified")).toBe(false); const keys = new Set(EXPECTED_PRICE_OVERLAYS.map(row => `${row.provider}/${row.modelId}`)); for (const expected of [