From d375d75af2f83bc22a7d461bc9dffb9fc89df329 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:55:57 +0000 Subject: [PATCH 001/126] chore(release): open dev at 2.51.0 before releasing 2.50.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 595abbbf65..936324ccec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.50.0", + "version": "2.51.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 14d307a3e2faf85b04d188e2446ac985edd10ee1 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 17:53:57 +0900 Subject: [PATCH 002/126] docs(devlog): plan the 2.50.0 regression audit and release train Baseline v2.49.0 (main 2f3f73629) against dev 12c248f52 is 127 commits and 121 product files (+3322/-280). Records the five read-only audit lanes, the blocker definition, the release order, and the evidence ledger the cycles fill. --- .../000_plan.md | 36 +++++++++++ .../010_audit_lanes.md | 59 +++++++++++++++++++ .../020_release_plan.md | 30 ++++++++++ .../030_evidence.md | 31 ++++++++++ 4 files changed, 156 insertions(+) create mode 100644 devlog/_plan/260910_250_regression_audit_release/000_plan.md create mode 100644 devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md create mode 100644 devlog/_plan/260910_250_regression_audit_release/020_release_plan.md create mode 100644 devlog/_plan/260910_250_regression_audit_release/030_evidence.md diff --git a/devlog/_plan/260910_250_regression_audit_release/000_plan.md b/devlog/_plan/260910_250_regression_audit_release/000_plan.md new file mode 100644 index 0000000000..730db933ae --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/000_plan.md @@ -0,0 +1,36 @@ +# 2.50.0 regression audit and release — scope + +## Baseline and candidate + +- Released baseline: `v2.49.0`, `main` at `2f3f736299dca38861f8fb9c4326a4b4d7c664bc`. +- Audit candidate: `origin/dev` at `12c248f52bed88ea13be5b284c79a238feb592d1`, `package.json` version `2.50.0`. +- Delta: 127 commits, 1066 files. Product surface is 121 files / +3322 / -280 across + `src` (62), `gui` (27), `tests` (42), `docs-site` (28), plus one `scripts/test-layout/layout.json` + entry and the `package.json` bump. The remaining 885 files are `devlog`, which carries no runtime. + +## What this unit does + +Audit the product delta for release-blocking regressions, remediate anything blocking, +then run the 2.50.0 train: pre-move `dev`, promote the frozen candidate to `preview` +and `main`, publish to npm, and verify the artifacts independently. + +## Authorization in force + +The user authorized parallel `xai/grok-4.6` subagents, a regression-audit PABCD cycle, +and the release itself. Subagents are read-only verifiers; the main session owns every +PABCD transition, every write, and every external action. + +## Out of scope + +- Landing unrelated open pull requests. 20 PRs are open against `dev`; none is a + release prerequisite, and pulling them in would move the candidate tree mid-audit. +- Re-auditing anything already released in 2.49.0. +- Any change to `devlog/` history, `main` outside promotion, or third-party accounts. + +## Terminal outcomes + +- `DONE` — 2.50.0 on npm `latest` with `gitHead` matching the promoted `main` SHA, a + git tag, a GitHub release, and a recorded triage for every audit finding. +- `BLOCKED` — a release-blocking regression that cannot be fixed inside this scope, + or a missing external permission (npm trusted publishing, workflow dispatch). +- `NOOP` — the candidate is already published and verified. diff --git a/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md b/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md new file mode 100644 index 0000000000..e4a36ec292 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md @@ -0,0 +1,59 @@ +# Audit lanes + +Five read-only lanes, each dispatched to an independent `xai/grok-4.6` subagent with a +forked-context-free packet. Read scopes are stated per lane so a finding can be traced to +one owner; the lanes never write, and the main session de-duplicates the returns. + +Every lane compares `2f3f73629...origin/dev` and must return exact `path:line` anchors. +A lane that finds nothing returns "no blocker" with the files it actually read. + +## L1 — Responses and request pipeline + +`src/server/responses/{core,compact,context-overflow,policy-fallback,codex-ws-wire}.ts`, +`src/server/{chat-completions,chat-native,claude-messages,images,search,request-decompress,request-log}.ts`, +`src/claude/inbound.ts`, `src/web-search/{passthrough-bridge,ollama-executor}.ts`. + +Highest-risk lane: it owns the new hosted web-search bridge (+657 test lines), the +non-streaming context-overflow classification, agent-task recovery on mid-thread model +switches, and the configurable inbound body admission limit. + +## L2 — Codex accounts, quota, OAuth + +`src/codex/{account-runtime-state,account-store,account-usability,auth-api,auth-context,inject,quota,quota-auto-refresh}.ts`, +`src/oauth/{health,index,token-guardian}.ts`, `src/cli/{account,account-api,account-auth,account-extended}.ts`. + +Carries deferred validation, revoked pool grants, reauth-state clearing, the new account +plan field, and the token guardian. + +## L3 — Catalog, providers, combos, config + +`src/codex/catalog/{parsing,provider-fetch,sync}.ts`, `src/providers/{registry,quota,google-ai-studio-model-discovery,opencode-zen-rate-limit}.ts`, +`src/combos/{index,resolve}.ts`, `src/config.ts`, `src/types.ts`, `src/types/{accounts,config,provider}.ts`, +`src/clients/config-export/zcode.ts`, `src/lib/errors.ts`. + +Carries free-model pricing classification and filtering, quota-exhausted inactive marking, +AI Studio discovery restoration, and cross-provider blocked-model redirects. + +## L4 — Management API, service, GUI + +`src/server/management/*`, `src/server/{management-api,auth-cors,index}.ts`, `src/service.ts`, +`gui/src/**`, `gui/tests/**`. + +Carries the routed-account log label, the decode-rate column, management auth, the stale +launchd bootout recovery, and nine i18n locale files that must not contradict `en`. + +## L5 — Security, privacy, release surface + +Cross-cutting read of `src/lib/privacy.ts`, the body-size admission path, OrcaRouter +key-exchange bounds, web-search bridge egress, `package.json`, `scripts/test-layout/layout.json`, +and the repository invariants in `AGENTS.md` (Lab/core import boundary, no tracked gitlink, +no request-body or credential logging). + +The email-masking opt-out is the specific item to scrutinize: it deliberately weakens a +privacy default, so it must be off by default and covered by `bun run privacy:scan`. + +## Blocker definition + +A finding blocks the release when it is a behavior regression against 2.49.0, a crash or +hang on a default path, a security or privacy weakening, or a broken release/packaging +surface. Style, missing coverage for unchanged code, and pre-existing defects do not block. diff --git a/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md b/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md new file mode 100644 index 0000000000..8233904bf1 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md @@ -0,0 +1,30 @@ +# 2.50.0 release plan + +Order matters: the candidate tree must be frozen before `dev` moves again. + +1. **Freeze the candidate.** Record the exact `dev` SHA and require successful + `Cross-platform CI` and `Service lifecycle` runs on that SHA. `dev` HEAD + `12c248f52` currently has no run of its own, so the release SHA needs an explicit + `lane=all` CI dispatch plus a service-lifecycle run before promotion. +2. **Land blockers first.** Any wp3 fix goes to `dev` through a pull request, which moves + the candidate; re-freeze and re-verify CI on the new SHA rather than reusing old-head green. +3. **Pre-move `dev`.** Bump `dev` past the stable target with `scripts/bump-dev-version.ts` + through a one-file PR, so `dev` is never equal to the published stable version. +4. **Promote independently.** Open promotion PRs from the frozen candidate into `preview` + and `main`. Verify the candidate is an ancestor of both; do not assume `preview` + precedes `main`. +5. **Publish.** Dispatch `release.yml` with `expected-sha` pinned to the promoted `main` + SHA. Do not run `scripts/release.ts` locally: it requires a clean tree and runs the + full suite, audit, and privacy scan in a worktree that is not the release worktree. +6. **Verify artifacts.** `npm view @bitkyc08/opencodex dist-tags`, the `2.50.0` `gitHead` + against the promoted `main` SHA, the git tag, the GitHub release, tarball integrity, + and SLSA provenance. npm propagation lag returns 404 or a stale `latest`; poll, never + republish. + +## Known failure modes to expect + +- Branch-keyed CI concurrency cancels an older run when a newer commit lands. A cancelled + aggregate is neither a product failure nor passing evidence. +- `dev-version-bump.yml` is `workflow_call`-only and cannot be dispatched by hand. +- The registry-availability smoke can time out after npm already accepted the publish. + Inspect metadata, provenance, and tarball before considering a retry. diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md new file mode 100644 index 0000000000..e73ca9f565 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md @@ -0,0 +1,31 @@ +# Evidence ledger + +Filled as the cycles complete. Every row names the source of the claim. + +## Frozen facts + +| Item | Value | Source | +| --- | --- | --- | +| Released baseline | `v2.49.0` / `main` `2f3f736299dca38861f8fb9c4326a4b4d7c664bc` | `git log origin/main` | +| Audit candidate | `dev` `12c248f52bed88ea13be5b284c79a238feb592d1` | `git rev-parse origin/dev` | +| Candidate version | `2.50.0` | `package.json` | +| Product delta | 121 files, +3322 / -280 | `git diff --shortstat 2f3f73629...origin/dev -- src gui docs-site scripts .github` | + +## Audit findings + +| ID | Lane | File:line | Failure mode | Blocker | Disposition | +| --- | --- | --- | --- | --- | --- | +| _pending_ | | | | | | + +## Release artifacts + +| Gate | Evidence | Status | +| --- | --- | --- | +| Cross-platform CI on release SHA | | pending | +| Service lifecycle on release SHA | | pending | +| `dev` pre-move | | pending | +| `preview` promotion | | pending | +| `main` promotion | | pending | +| npm `latest` = 2.50.0 | | pending | +| `gitHead` matches promoted `main` | | pending | +| git tag + GitHub release | | pending | From 480ecd7fb546d5f009de22066b1605a449f9c32a Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 18:11:03 +0900 Subject: [PATCH 003/126] docs(devlog): fold the roadmap audit findings into the 2.50.0 plan The independent reviewer failed the first roadmap: four src/cli files were in no lane, the blocker definition could not catch new-path breakage or an AGENTS.md invariant violation, and the release order did not match what release.yml actually gates on. Adds lane L6, rewrites the blocker definition to eight clauses, and rebuilds the release order around the main promotion merge SHA, the armed service-lifecycle gate, the dry-run default, and the fact that preview refuses a non-preview version line. --- .../000_plan.md | 37 ++++++++-- .../010_audit_lanes.md | 73 ++++++++++++++----- .../020_release_plan.md | 69 +++++++++++++----- .../030_evidence.md | 37 ++++++++-- 4 files changed, 164 insertions(+), 52 deletions(-) diff --git a/devlog/_plan/260910_250_regression_audit_release/000_plan.md b/devlog/_plan/260910_250_regression_audit_release/000_plan.md index 730db933ae..44cdd974db 100644 --- a/devlog/_plan/260910_250_regression_audit_release/000_plan.md +++ b/devlog/_plan/260910_250_regression_audit_release/000_plan.md @@ -4,15 +4,35 @@ - Released baseline: `v2.49.0`, `main` at `2f3f736299dca38861f8fb9c4326a4b4d7c664bc`. - Audit candidate: `origin/dev` at `12c248f52bed88ea13be5b284c79a238feb592d1`, `package.json` version `2.50.0`. -- Delta: 127 commits, 1066 files. Product surface is 121 files / +3322 / -280 across - `src` (62), `gui` (27), `tests` (42), `docs-site` (28), plus one `scripts/test-layout/layout.json` - entry and the `package.json` bump. The remaining 885 files are `devlog`, which carries no runtime. +- Delta: 127 commits (`git rev-list --count 2f3f73629..origin/dev`), 1066 changed files + (`git diff --name-only 2f3f73629...origin/dev | wc -l`). + +### File counts, by `git diff --name-only 2f3f73629...origin/dev | cut -d/ -f1 | sort | uniq -c` + +| Group | Files | Note | +| --- | --- | --- | +| `devlog` | 885 | No runtime. Not audited. | +| `src` | 62 | Audited: L1-L3, L5, L6 | +| `tests` | 42 | Read as evidence by every lane, not a lane of its own | +| `docs-site` | 28 | Not release-blocking on its own | +| `gui` | 27 | Audited: L4 | +| `readme` + root docs | 13 | `readme/` 8, plus `README.md`, `AGENTS.md`, `AGENTS_INSTALL.md`, `SECURITY.md`, `package.json` | +| `skills` | 3 | Audited: L6 (`skills/ocx` surface map) | +| `.github` | 3 | PR assets only, no workflow change | +| `structure` | 2 | Maintainer invariants | +| `scripts` | 1 | `scripts/test-layout/layout.json` | + +`git diff --shortstat 2f3f73629...origin/dev -- src gui docs-site scripts .github` is +121 files / +3322 / -280. That figure excludes `tests` and `package.json`; the full +non-`devlog` set is 181 files. An earlier revision of this doc attributed 121 to a +different folder set and derived the devlog count by subtraction; the reviewer +contradicted both with the commands above. ## What this unit does Audit the product delta for release-blocking regressions, remediate anything blocking, -then run the 2.50.0 train: pre-move `dev`, promote the frozen candidate to `preview` -and `main`, publish to npm, and verify the artifacts independently. +then run the 2.50.0 train: pre-move `dev`, promote the frozen candidate to `main`, +publish to npm, and verify the artifacts independently. ## Authorization in force @@ -22,10 +42,11 @@ PABCD transition, every write, and every external action. ## Out of scope -- Landing unrelated open pull requests. 20 PRs are open against `dev`; none is a - release prerequisite, and pulling them in would move the candidate tree mid-audit. +- Landing unrelated open pull requests. 74 are open against `dev` + (`gh api "repos/lidge-jun/opencodex/pulls?state=open&base=dev&per_page=100" --jq 'length'`); + none is a release prerequisite, and pulling one in moves the candidate mid-audit. - Re-auditing anything already released in 2.49.0. -- Any change to `devlog/` history, `main` outside promotion, or third-party accounts. +- Any change to `devlog/` history or to third-party accounts. ## Terminal outcomes diff --git a/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md b/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md index e4a36ec292..1e8aa92f95 100644 --- a/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md +++ b/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md @@ -1,19 +1,24 @@ # Audit lanes -Five read-only lanes, each dispatched to an independent `xai/grok-4.6` subagent with a -forked-context-free packet. Read scopes are stated per lane so a finding can be traced to -one owner; the lanes never write, and the main session de-duplicates the returns. +Six read-only lanes, each dispatched to an independent `xai/grok-4.6` subagent with a +fresh context. Read scopes are stated per lane so a finding traces to one owner; the +lanes never write, and the main session de-duplicates the returns. Every lane compares `2f3f73629...origin/dev` and must return exact `path:line` anchors. A lane that finds nothing returns "no blocker" with the files it actually read. +Lane coverage is checked mechanically: every path in +`git diff --name-only 2f3f73629...origin/dev -- src gui scripts skills package.json` +belongs to at least one lane. The first revision of this map left +`src/cli/{capabilities,index,models-runtime,observe}.ts` unowned, which is why L6 exists. + ## L1 — Responses and request pipeline `src/server/responses/{core,compact,context-overflow,policy-fallback,codex-ws-wire}.ts`, `src/server/{chat-completions,chat-native,claude-messages,images,search,request-decompress,request-log}.ts`, `src/claude/inbound.ts`, `src/web-search/{passthrough-bridge,ollama-executor}.ts`. -Highest-risk lane: it owns the new hosted web-search bridge (+657 test lines), the +Highest-risk lane: the new hosted web-search bridge (`passthrough-bridge.ts` +761), the non-streaming context-overflow classification, agent-task recovery on mid-thread model switches, and the configurable inbound body admission limit. @@ -22,8 +27,8 @@ switches, and the configurable inbound body admission limit. `src/codex/{account-runtime-state,account-store,account-usability,auth-api,auth-context,inject,quota,quota-auto-refresh}.ts`, `src/oauth/{health,index,token-guardian}.ts`, `src/cli/{account,account-api,account-auth,account-extended}.ts`. -Carries deferred validation, revoked pool grants, reauth-state clearing, the new account -plan field, and the token guardian. +Deferred validation, revoked pool grants, reauth-state clearing, the new account plan +field, and the token guardian. ## L3 — Catalog, providers, combos, config @@ -31,29 +36,63 @@ plan field, and the token guardian. `src/combos/{index,resolve}.ts`, `src/config.ts`, `src/types.ts`, `src/types/{accounts,config,provider}.ts`, `src/clients/config-export/zcode.ts`, `src/lib/errors.ts`. -Carries free-model pricing classification and filtering, quota-exhausted inactive marking, -AI Studio discovery restoration, and cross-provider blocked-model redirects. +Free-model pricing classification and filtering, quota-exhausted inactive marking, AI +Studio discovery restoration, cross-provider blocked-model redirects. ## L4 — Management API, service, GUI `src/server/management/*`, `src/server/{management-api,auth-cors,index}.ts`, `src/service.ts`, `gui/src/**`, `gui/tests/**`. -Carries the routed-account log label, the decode-rate column, management auth, the stale -launchd bootout recovery, and nine i18n locale files that must not contradict `en`. +The routed-account log label, the decode-rate column, management auth, the stale launchd +bootout recovery, and nine i18n locale files that must not contradict `en`. ## L5 — Security, privacy, release surface Cross-cutting read of `src/lib/privacy.ts`, the body-size admission path, OrcaRouter -key-exchange bounds, web-search bridge egress, `package.json`, `scripts/test-layout/layout.json`, -and the repository invariants in `AGENTS.md` (Lab/core import boundary, no tracked gitlink, -no request-body or credential logging). +key-exchange bounds, web-search bridge egress, `package.json`, +`scripts/test-layout/layout.json`, `structure/{02_config-and-codex-home,04_transports-and-sidecars}.md`, +and the repository invariants in `AGENTS.md`: the Lab/core import boundary, the +synchronous `startServer` window, no tracked gitlink, and no request-body or credential +logging. The email-masking opt-out is the specific item to scrutinize: it deliberately weakens a -privacy default, so it must be off by default and covered by `bun run privacy:scan`. +privacy default, so it must be off by default, must survive `bun run privacy:scan`, and +its CLI application in `src/cli/index.ts` (L6) must agree with the library default. + +## L6 — Operator CLI surface + +`src/cli/{capabilities,index,models-runtime,observe}.ts`, `skills/ocx/**`, and the +generated surface map that `tests/ci-workflows/skill-ocx.test.ts` asserts. + +`src/cli/index.ts` applies `privacy.maskEmails` to `ocx status` and is a +`service-lifecycle.yml` gate path. `capabilities.ts` adds a mutating `ocx account refresh` +with a consent warning. `models-runtime.ts` adds `--free-only`, which filters on +`pricingStatus === "free"` and therefore drops entries with no status. `observe.ts` adds +`--account` log filtering. ## Blocker definition -A finding blocks the release when it is a behavior regression against 2.49.0, a crash or -hang on a default path, a security or privacy weakening, or a broken release/packaging -surface. Style, missing coverage for unchanged code, and pre-existing defects do not block. +A finding blocks the release when any of these hold. + +1. **Regression against 2.49.0** — behavior that worked in the released tree and does not now. +2. **Crash, hang, or unbounded resource use** on any path a default install can reach. +3. **New-path functional breakage.** A feature introduced in this delta that does not do + what it claims still blocks, even though it is not a regression. This covers the + web-search bridge returning wrong or empty results, `--free-only` silently dropping + models with an absent `pricingStatus`, and a no-op `ocx account refresh`. +4. **Security or privacy weakening**, including a default that becomes less private, a + credential or request body reaching a log, or a loosened auth boundary. +5. **User-consent or identity-spend bypass**, per `AGENTS.md` "User-consent actions": any + path that spends the user's identity, credits, or reputation without the code-level + gate, including a CLI path that mints its own dashboard session. +6. **Core invariant violation**, per `AGENTS.md`: a Lab import reaching `src/router.ts`, + `src/server/lifecycle.ts`, or `src/server/responses/core.ts`, an `await` inside the + synchronous `startServer` activation window, or a tracked gitlink. +7. **Upgrade-path breakage**, not only first-run. An existing 2.49.0 install that keeps a + stale launchd job, a stale config, or a stale service unit after upgrading blocks. +8. **Broken release, packaging, or operator-surface contract**, including a `skills/ocx` + map that names a command the registry does not have. + +Style, missing coverage for unchanged code, and defects that already shipped in 2.49.0 +do not block; they are recorded as non-blockers with the evidence that they predate the delta. diff --git a/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md b/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md index 8233904bf1..4c314d76c3 100644 --- a/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md +++ b/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md @@ -1,30 +1,61 @@ # 2.50.0 release plan -Order matters: the candidate tree must be frozen before `dev` moves again. +Derived from `.github/workflows/release.yml` as it exists on `dev`, not from precedent. +The gates below are what the workflow actually enforces. -1. **Freeze the candidate.** Record the exact `dev` SHA and require successful - `Cross-platform CI` and `Service lifecycle` runs on that SHA. `dev` HEAD - `12c248f52` currently has no run of its own, so the release SHA needs an explicit - `lane=all` CI dispatch plus a service-lifecycle run before promotion. +## What release.yml requires + +| Gate | Line | Requirement | +| --- | --- | --- | +| Branch | `release.yml:153-170` | Must run from `refs/heads/main` or `refs/heads/preview`. `main` refuses any version containing `-`; `preview` refuses any version that is not `*-preview.*`. | +| dist-tag | `release.yml:174-177` | `main` -> `latest`, `preview` -> `preview`. | +| CI | `release.yml:179-197` | A **successful `ci.yml` run with `--event push` on the release branch for `$GITHUB_SHA`**. A pull-request run is explicitly rejected, and a `workflow_dispatch` run on `dev` does not qualify. | +| Service lifecycle | `release.yml:225-237` | If any of `src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, `service-lifecycle.yml`, `release.yml` changed since the previous tag, a successful `service-lifecycle.yml` run for `$GITHUB_SHA` is required. This delta changes `src/cli/index.ts` and `package.json`, so the gate is armed. | +| dev ahead | `release.yml:242-249` | `bun scripts/version-line.ts assert-ahead `. `dev` is currently `2.50.0`, so publishing 2.50.0 fails until `dev` is pre-moved. | +| Publish | `release.yml:22-26` | `dry-run` defaults to **true**. A real publish needs `dry-run=false`. `expected-sha` is required and must equal the branch head at dispatch. | + +`$GITHUB_SHA` on `main` is the **promotion merge commit**, not the frozen `dev` SHA. +2.49.0 published from merge `2f3f73629`, not from its promoted tree commit `62849dfa6`. +Both `ci.yml` (`push: branches: [main, preview, dev]`, `paths: src/**, gui/**, ...`) and +`service-lifecycle.yml` (`push`, paths including `package.json` and `src/cli/index.ts`) +fire automatically on that merge, so the required runs appear without a dispatch — but +they must be waited for on that exact SHA. + +## Order + +1. **Freeze the candidate.** Record the exact `dev` SHA. A `workflow_dispatch` `lane=all` + run on `dev` is audit evidence for the tree, not the release gate; it tells us whether + the tree is green before we spend a promotion on it. 2. **Land blockers first.** Any wp3 fix goes to `dev` through a pull request, which moves - the candidate; re-freeze and re-verify CI on the new SHA rather than reusing old-head green. -3. **Pre-move `dev`.** Bump `dev` past the stable target with `scripts/bump-dev-version.ts` - through a one-file PR, so `dev` is never equal to the published stable version. -4. **Promote independently.** Open promotion PRs from the frozen candidate into `preview` - and `main`. Verify the candidate is an ancestor of both; do not assume `preview` - precedes `main`. -5. **Publish.** Dispatch `release.yml` with `expected-sha` pinned to the promoted `main` - SHA. Do not run `scripts/release.ts` locally: it requires a clean tree and runs the - full suite, audit, and privacy scan in a worktree that is not the release worktree. -6. **Verify artifacts.** `npm view @bitkyc08/opencodex dist-tags`, the `2.50.0` `gitHead` - against the promoted `main` SHA, the git tag, the GitHub release, tarball integrity, - and SLSA provenance. npm propagation lag returns 404 or a stale `latest`; poll, never - republish. + the candidate. Re-freeze and re-verify on the new SHA; old-head green is not evidence. +3. **Pre-move `dev`.** Dispatch `dev-version-bump.yml` (it is `on: workflow_dispatch`, + not `workflow_call`-only) with `intended-version=2.50.0`, `mode=pre-move`, from the + default branch. It opens a pull request and does **not** push to `dev`, because the + `Protect dev` ruleset requires review. Merge that PR so `dev` reads 2.51.0 before the + publish reaches `assert-ahead`. Use the workflow rather than a hand-written one-file PR + so its tag/npm/version-line proofs run. +4. **Promote to `main`.** Open a promotion PR carrying the frozen candidate tree into + `main`, with the version already at `2.50.0`. Merge it. Record the merge SHA. +5. **Wait for the release-branch gates on the merge SHA.** Push-event `ci.yml` and + `service-lifecycle.yml` on `main` for that exact SHA, both successful. +6. **Dry-run, then publish.** Dispatch `release.yml` with `--ref main`, + `version=2.50.0`, `tag=latest`, `expected-sha=`, first with + `dry-run=true`, then with `dry-run=false` once the dry run is green. +7. **Verify artifacts.** `npm view @bitkyc08/opencodex dist-tags`, the `2.50.0` + `gitHead` against the promoted `main` SHA, the git tag, the GitHub release, tarball + integrity, and SLSA provenance. npm propagation lag returns 404 or a stale `latest`; + poll, never republish. +8. **`preview` is a separate line and is not part of this stable train.** + `origin/preview` is `2.49.0-preview.20260909`, and `release.yml:161-165` refuses a + preview publish whose version is not `*-preview.*`. Promoting the plain `2.50.0` tree + onto `preview` would break that branch's version line. If `preview` should carry this + tree, it needs its own `2.50.0-preview.` commit, decided after the stable + release lands. A branch sync and a preview npm publication are distinct operations. ## Known failure modes to expect - Branch-keyed CI concurrency cancels an older run when a newer commit lands. A cancelled aggregate is neither a product failure nor passing evidence. -- `dev-version-bump.yml` is `workflow_call`-only and cannot be dispatched by hand. - The registry-availability smoke can time out after npm already accepted the publish. Inspect metadata, provenance, and tarball before considering a retry. +- `dev-version-bump.yml` rejects a dispatch from a non-default ref as an early warning. diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md index e73ca9f565..78a46bfb7d 100644 --- a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md +++ b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md @@ -9,23 +9,44 @@ Filled as the cycles complete. Every row names the source of the claim. | Released baseline | `v2.49.0` / `main` `2f3f736299dca38861f8fb9c4326a4b4d7c664bc` | `git log origin/main` | | Audit candidate | `dev` `12c248f52bed88ea13be5b284c79a238feb592d1` | `git rev-parse origin/dev` | | Candidate version | `2.50.0` | `package.json` | -| Product delta | 121 files, +3322 / -280 | `git diff --shortstat 2f3f73629...origin/dev -- src gui docs-site scripts .github` | +| Commits in delta | 127 | `git rev-list --count 2f3f73629..origin/dev` | +| Changed files | 1066 total, 885 `devlog`, 181 non-`devlog` | `git diff --name-only 2f3f73629...origin/dev` | +| `preview` version line | `2.49.0-preview.20260909` | `git show origin/preview:package.json` | +| Open PRs against `dev` | 74 | `gh api "repos/lidge-jun/opencodex/pulls?state=open&base=dev&per_page=100" --jq 'length'` | -## Audit findings +## wp1 — roadmap audit (A gate) + +Reviewer: `xai/grok-4.6`, agent `01a08a86-1352-77a2-98bd-5c167b5479c8`, read-only, fresh context. +Verdict: **FAIL**. Every finding was verified independently by the main session before folding. + +| # | Finding | Verified by | Fold | +| --- | --- | --- | --- | +| R1 | `src/cli/{capabilities,index,models-runtime,observe}.ts` belonged to no lane | `git diff --name-only` vs the lane map | Lane **L6** added | +| R2 | Blocker definition missed new-path breakage, consent/identity-spend bypass, `AGENTS.md` core invariants, upgrade-path recovery, and operator-surface drift | `AGENTS.md:43-83`, `AGENTS.md:150-169` | Definition rewritten to 8 clauses | +| R3 | `release.yml:179-197` needs a push-event `ci.yml` run on the release branch for `$GITHUB_SHA`; a `dev` dispatch does not qualify | `sed -n '179,197p' .github/workflows/release.yml` | Order rewritten: gates run on the `main` merge SHA | +| R4 | `service-lifecycle.yml` is gated on `$GITHUB_SHA`, and this delta arms it via `src/cli/index.ts` + `package.json` | `sed -n '225,237p' .github/workflows/release.yml` | Made an explicit step on the merge SHA | +| R5 | `preview` refuses a non-`*-preview.*` version, and `origin/preview` is `2.49.0-preview.20260909` | `sed -n '161,165p' release.yml`; `git show origin/preview:package.json` | `preview` removed from the stable train | +| R6 | `dev-version-bump.yml` is `on: workflow_dispatch`, not `workflow_call`-only | `sed -n '24,45p' .github/workflows/dev-version-bump.yml` | Pre-move now uses the workflow, not a hand PR | +| R7 | `dry-run` defaults to `true` and the run must come from `refs/heads/main` | `sed -n '22,26p'`, `sed -n '153,170p'` `release.yml` | Dry-run-then-publish made explicit | +| R8 | Scope doc misattributed the 121-file figure, derived the devlog count by subtraction, and said 20 open PRs | `git diff --shortstat`; `gh api ... --jq 'length'` -> 74 | Counts table rewritten from the real command | + +## Audit findings (wp2) | ID | Lane | File:line | Failure mode | Blocker | Disposition | | --- | --- | --- | --- | --- | --- | | _pending_ | | | | | | -## Release artifacts +## Release artifacts (wp4) | Gate | Evidence | Status | | --- | --- | --- | -| Cross-platform CI on release SHA | | pending | -| Service lifecycle on release SHA | | pending | -| `dev` pre-move | | pending | -| `preview` promotion | | pending | -| `main` promotion | | pending | +| Candidate-tree CI (`dev` dispatch, audit evidence) | run 34457689927 on `12c248f52` | pending | +| `dev` pre-move to 2.51.0 | | pending | +| `main` promotion merge SHA | | pending | +| Push-event Cross-platform CI on merge SHA | | pending | +| Service lifecycle on merge SHA | | pending | +| `release.yml` dry run | | pending | +| `release.yml` publish | | pending | | npm `latest` = 2.50.0 | | pending | | `gitHead` matches promoted `main` | | pending | | git tag + GitHub release | | pending | From ecd27aaa7767a358be830d93e93edbf9fa86ce6f Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 18:18:10 +0900 Subject: [PATCH 004/126] docs(devlog): pin the 2.50.0 promotion source and method Round 2 of the roadmap audit returned GO-WITH-FIXES with three new findings. After the dev pre-move, origin/dev is 2.51.0, so the promotion step now names the recorded freeze SHA instead of "current dev". That SHA is not an ancestor of main, so the step documents the 2.49.0 branch-and-merge method and makes tree equality the gate rather than a readable diff. Also corrects the lane attribution for src and drops OrcaRouter from L5, which is not in this delta. --- .../000_plan.md | 2 +- .../010_audit_lanes.md | 4 +-- .../020_release_plan.md | 32 ++++++++++++++----- .../030_evidence.md | 14 ++++++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/devlog/_plan/260910_250_regression_audit_release/000_plan.md b/devlog/_plan/260910_250_regression_audit_release/000_plan.md index 44cdd974db..604eaa2a17 100644 --- a/devlog/_plan/260910_250_regression_audit_release/000_plan.md +++ b/devlog/_plan/260910_250_regression_audit_release/000_plan.md @@ -12,7 +12,7 @@ | Group | Files | Note | | --- | --- | --- | | `devlog` | 885 | No runtime. Not audited. | -| `src` | 62 | Audited: L1-L3, L5, L6 | +| `src` | 62 | Audited: L1-L6 (L4 owns `src/server/management/*`, `src/server/{management-api,auth-cors,index}.ts`, `src/service.ts`) | | `tests` | 42 | Read as evidence by every lane, not a lane of its own | | `docs-site` | 28 | Not release-blocking on its own | | `gui` | 27 | Audited: L4 | diff --git a/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md b/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md index 1e8aa92f95..f469ec2e9d 100644 --- a/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md +++ b/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md @@ -49,8 +49,8 @@ bootout recovery, and nine i18n locale files that must not contradict `en`. ## L5 — Security, privacy, release surface -Cross-cutting read of `src/lib/privacy.ts`, the body-size admission path, OrcaRouter -key-exchange bounds, web-search bridge egress, `package.json`, +Cross-cutting read of `src/lib/privacy.ts`, the body-size admission path, web-search +bridge egress, `package.json`, `scripts/test-layout/layout.json`, `structure/{02_config-and-codex-home,04_transports-and-sidecars}.md`, and the repository invariants in `AGENTS.md`: the Lab/core import boundary, the synchronous `startServer` window, no tracked gitlink, and no request-body or credential diff --git a/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md b/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md index 4c314d76c3..a96f9faca2 100644 --- a/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md +++ b/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md @@ -28,14 +28,30 @@ they must be waited for on that exact SHA. the tree is green before we spend a promotion on it. 2. **Land blockers first.** Any wp3 fix goes to `dev` through a pull request, which moves the candidate. Re-freeze and re-verify on the new SHA; old-head green is not evidence. -3. **Pre-move `dev`.** Dispatch `dev-version-bump.yml` (it is `on: workflow_dispatch`, - not `workflow_call`-only) with `intended-version=2.50.0`, `mode=pre-move`, from the - default branch. It opens a pull request and does **not** push to `dev`, because the - `Protect dev` ruleset requires review. Merge that PR so `dev` reads 2.51.0 before the - publish reaches `assert-ahead`. Use the workflow rather than a hand-written one-file PR - so its tag/npm/version-line proofs run. -4. **Promote to `main`.** Open a promotion PR carrying the frozen candidate tree into - `main`, with the version already at `2.50.0`. Merge it. Record the merge SHA. +3. **Pre-move `dev`.** Dispatch `dev-version-bump.yml` with `intended-version=2.50.0`, + `mode=pre-move`. It is `on: workflow_dispatch`, but `dev-version-bump.yml:79` refuses + a non-default ref, so dispatch it with `--ref main`. It opens a pull request and does + **not** push to `dev`, because the `Protect dev` ruleset requires review. Merge that PR + so `dev` reads 2.51.0 before the publish reaches `assert-ahead`. Use the workflow rather + than a hand-written one-file PR so its tag/npm/version-line proofs run. +4. **Promote the frozen SHA to `main`, not current `dev`.** After step 3, `origin/dev` is + 2.51.0 and is no longer the candidate. Promotion always names the recorded freeze SHA + explicitly. + + The freeze SHA is **not** an ancestor of `main`, and `main` carries commits `dev` does + not, so there is nothing to fast-forward. Replicate the 2.49.0 method: branch from + `main`, merge the freeze SHA into that branch as a single + `release: promote verified 2.50.0 product tree to main` commit, then open the PR into + `main`. For 2.49.0 that was branch `codex/release-249-main-01a08498`, promote commit + `62849dfa6` (parents `9a27e8699` = old `main`, `ad36c7be8` = the dev freeze), merged by + PR #4117 as `2f3f73629`. + + The gate on this step is **tree equality**, not a green diff: after promotion, + `git rev-parse
^{tree}` must equal `git rev-parse ^{tree}`. + For 2.49.0 all three of the promote commit, the dev freeze, and the merged `main` tip + resolved to tree `66294fb3eb15592afd732f8b8e29d0bcc644fe9e`. Any conflict resolution + that changes that tree means a different product shipped than the one audited. + Record the merge SHA. 5. **Wait for the release-branch gates on the merge SHA.** Push-event `ci.yml` and `service-lifecycle.yml` on `main` for that exact SHA, both successful. 6. **Dry-run, then publish.** Dispatch `release.yml` with `--ref main`, diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md index 78a46bfb7d..761f26e126 100644 --- a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md +++ b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md @@ -30,6 +30,20 @@ Verdict: **FAIL**. Every finding was verified independently by the main session | R7 | `dry-run` defaults to `true` and the run must come from `refs/heads/main` | `sed -n '22,26p'`, `sed -n '153,170p'` `release.yml` | Dry-run-then-publish made explicit | | R8 | Scope doc misattributed the 121-file figure, derived the devlog count by subtraction, and said 20 open PRs | `git diff --shortstat`; `gh api ... --jq 'length'` -> 74 | Counts table rewritten from the real command | +Round 2 verdict: **GO-WITH-FIXES**. R1-R8 all confirmed FIXED with anchors, and the +mechanical lane-coverage check over the 94 changed product paths returned zero unlaned. +Three new findings were raised and folded: + +| # | Finding | Verified by | Fold | +| --- | --- | --- | --- | +| R9 | After the pre-move, `origin/dev` is 2.51.0; promoting current `dev` would publish the wrong version. The plan never pinned the promotion source to the freeze SHA | `020_release_plan.md:37` as written | Step 4 now names the recorded freeze SHA explicitly | +| R10 | The freeze SHA is not an ancestor of `main` and `main` carries commits `dev` lacks, so a naive `base=main head=` PR is a 127-commit history merge rather than a tree promotion | `git merge-base --is-ancestor 12c248f52 origin/main` -> 1 | Step 4 documents the 2.49.0 branch-and-merge method and makes **tree equality** the gate: promote tree, dev freeze tree, and merged `main` tree all resolved to `66294fb3eb15592afd732f8b8e29d0bcc644fe9e` for 2.49.0 | +| R11 | `000_plan.md` said `src` is audited by L1-L3, L5, L6, but L4 owns `src/server/management/*` and `src/service.ts` | `010_audit_lanes.md:44` | Counts table corrected to L1-L6 | + +Also folded from the round-2 residual: `dev-version-bump.yml:79` refuses a non-default +ref, so the pre-move dispatch must use `--ref main`; and L5 no longer names OrcaRouter +key-exchange bounds, which are not in this delta. + ## Audit findings (wp2) | ID | Lane | File:line | Failure mode | Blocker | Disposition | From fb2118ecf5ef2379b2197795ffadb9b29027e43c Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 18:19:02 +0900 Subject: [PATCH 005/126] docs(devlog): write the wp3 triage and remediation protocol The audit lanes produce findings; this is the pre-written decade doc that turns them into a release decision. Requires an anchor before a finding is admitted, gives PRE-EXISTING a proof obligation against the released tree, and states what happens to the freeze SHA when a fix lands. Forbids reclassifying a blocker to reach a release, and names reverting the offending range as the allowed alternative. --- .../040_triage_protocol.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md diff --git a/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md b/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md new file mode 100644 index 0000000000..ee6a04a1d7 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md @@ -0,0 +1,57 @@ +# Triage and remediation protocol (wp3) + +wp2 returns six lane reports. This is how they become a release decision. + +## 1. Normalize + +Each lane return is split into individual findings. A finding is only admitted with an +exact `path:line` anchor or a literal command and its output. An unanchored assertion is +recorded as **unsubstantiated** and re-derived by the main session or dropped; it never +blocks and it never passes silently. + +Findings from different lanes that name the same defect are merged, keeping every anchor. + +## 2. Classify + +Apply the eight-clause blocker definition in `010_audit_lanes.md`. Each finding gets +exactly one disposition. + +| Disposition | Meaning | Action | +| --- | --- | --- | +| `BLOCK` | Matches a blocker clause | Must be fixed and landed on `dev` before promotion | +| `SHIP` | Real but does not match a clause | Recorded here, filed as an issue if it deserves one, released as is | +| `PRE-EXISTING` | Present in `2f3f73629` as well | Not this release's problem; prove it with a command against the released tree | +| `WRONG` | The lane misread the code | Rebutted with the anchor that disproves it | + +A finding is `PRE-EXISTING` only with proof: `git show 2f3f73629:` showing the same +defect, or a test that fails on the baseline. "It looks old" is not proof. + +## 3. Remediate + +Every `BLOCK` fix follows the repository's normal contribution path — a branch off the +current `dev`, a focused regression test next to the existing tests for that subsystem, +a pull request against `dev` using `.github/PULL_REQUEST_TEMPLATE.md`, and the exact-head +CI evidence the branch policy requires. No direct push to `dev`; the ruleset rejects it +regardless of `--no-verify`. + +Landing a fix **moves the candidate**. When that happens: + +1. Record the new `dev` SHA as the freeze SHA, superseding `12c248f52`. +2. Re-run the candidate-tree CI dispatch on the new SHA. Green on the old head proves + nothing about the new one. +3. Re-run only the lanes whose read scope intersects the fix, not all six. + +## 4. Escalate rather than weaken + +If a `BLOCK` cannot be fixed inside this scope — it needs a design decision, an external +credential, or a change the user has not authorized — the release stops and the outcome is +`BLOCKED`. Reclassifying a blocker to `SHIP` to reach a release is the one move this +protocol forbids. The alternative that *is* allowed: revert the offending commit range from +the candidate and release without that feature, which is a smaller change than shipping a +known defect. + +## 5. Record + +Every finding lands in the wp2 findings table in `030_evidence.md` with its ID, lane, +anchor, failure mode, disposition, and — for `BLOCK` — the PR and merge SHA that resolved +it. A finding with no row in that table did not happen. From 51105863bc21c28828b912bde790479df06e2293 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 18:21:18 +0900 Subject: [PATCH 006/126] docs(devlog): specify the six audit lane dispatch packets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the frame every lane shares — comparison base, read-only bounds, the anchor requirement, and a return format that separates what breaks from which blocker clause it hits — then gives each lane its own concrete questions rather than "audit this directory". States that nothing is accepted on a lane's authority: every reported blocker is re-derived by the main session first. --- .../050_lane_packets.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md diff --git a/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md b/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md new file mode 100644 index 0000000000..e5d4b7c540 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md @@ -0,0 +1,73 @@ +# Lane dispatch packets (wp2) + +Six `xai/grok-4.6` subagents, dispatched in one round, fresh context each, read-only. +They run concurrently because their questions are independent; none reads another's output. + +## Shared packet frame + +Every packet carries the same frame, with only `SCOPE` and `QUESTIONS` differing. + +- **Repository:** `/Users/jun/.codex/worktrees/b53a/opencodex`, branch + `codex/260910-250-regression-audit-release` at the freeze SHA. +- **Comparison:** `git diff 2f3f73629...origin/dev -- `. `2f3f73629` is + released `v2.49.0`; the right side is the 2.50.0 candidate. +- **MUST NOT:** no writes, edits, commits, pushes, stashes, or branch changes; no test + suite, typecheck, build, or install; no mutating `gh` call. Read-only `git` and + `gh api`/`gh run list` only. Do not fix anything found — report it. +- **PROOF:** every finding needs an exact `path:line` on the candidate side, or a literal + command with its output. Unanchored claims are discarded, so do not pad the return. +- **RETURN FORMAT:** `VERDICT` (`NO-BLOCKER` or `BLOCKERS-FOUND`), then one numbered entry + per finding with `ANCHOR`, `WHAT BREAKS` (the concrete user-visible failure and the input + that triggers it), `CLAUSE` (which of the eight blocker clauses, or `non-blocking`), and + `CONFIDENCE` (`certain` / `likely` / `needs-runtime-check`). Then `FILES READ`. +- **DECISION BOUNDARY:** the lane reports evidence and unresolved judgments. It does not + decide whether the release proceeds, does not rank against other lanes, and does not + weaken a finding because it looks hard to fix. + +A lane that finds nothing returns `NO-BLOCKER` and its `FILES READ`. A short honest +return beats a long speculative one; the triage protocol discards unanchored text anyway. + +## Per-lane questions + +**L1 — responses and request pipeline.** Does the hosted web-search bridge arm only when +opted in, and does a failure fall back rather than hang or leak? Is the search cell placed +in stream order, and are bridge continuations bounded? Does the non-streaming +context-overflow classification return a classified reply on every exhausted-target path? +Does agent-task recovery on a mid-thread model switch preserve encrypted content? Does the +configurable body admission limit still have a safe default and reject rather than buffer? + +**L2 — codex accounts, quota, OAuth.** Can a deferred validation leave an account in a +state where it is neither usable nor visibly failed? Does a revoked pool grant reach a +terminal verdict instead of retrying forever? Does clearing reauth state ever clear it for +the wrong account? Does the new account plan field ever carry a value that identifies the +user into a log or the wire? + +**L3 — catalog, providers, combos, config.** Does free-model classification ever mark a +paid model free, or drop a model whose `pricingStatus` is absent rather than `"free"`? +Does quota-exhausted inactive marking recover when quota returns? Does the AI Studio +discovery restoration change behavior for custom gateways? Can a cross-provider blocked +model redirect cycle? + +**L4 — management API, service, GUI.** Does any management route lose its auth check? Does +the routed-account label reach a response a browser can read without a session? Does the +launchd bootout recovery ever tear down a healthy job? Do the nine i18n locales contradict +`en` on a destructive action's confirmation text? + +**L5 — security, privacy, release surface.** Is email masking still on by default, and does +the opt-out require an explicit operator action? Does `bun run privacy:scan`'s contract +still hold for every new log site? Does any `src/lab/` module now reach `src/router.ts`, +`src/server/lifecycle.ts`, or `src/server/responses/core.ts`? Is there any `await` in the +synchronous `startServer` activation window? Is any gitlink tracked? + +**L6 — operator CLI surface.** Does `ocx status` apply the same masking default as the +library? Does `ocx account refresh` mint or reuse a dashboard session, and does it spend +the user's identity without the code-level gate? Does `--free-only` drop models with an +absent `pricingStatus`? Does `--account` filtering match on a value that is masked in the +stored log? Does the committed `skills/ocx` surface map name any command +`src/cli/capabilities.ts` does not register? + +## What the main session does with the returns + +Nothing is accepted on the lane's authority. Every `BLOCKERS-FOUND` entry is re-derived +against the tree before it enters the wp2 findings table, exactly as the round-1 roadmap +findings were. `040_triage_protocol.md` governs from there. From d206d3c15a685f1f2e950f71e90c8c62d195d3e9 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 18:27:56 +0900 Subject: [PATCH 007/126] docs(devlog): close the packet and triage holes the third audit round found The frame claimed the worktree sat on the freeze SHA, which invited a lane to detach HEAD on the tree we are releasing from; it now states the real relationship and forbids checkout outright. The eight blocker clauses travel inline, because a lane cannot classify against a file it was never given, and the L5 privacy question no longer asks for a scan the frame forbids running. Each lane picked up the changes its questions would have missed: the Claude system-message hoist, the WS prelude timeout, the server-side account filter, the Zen free-tier rewrites, the config schema defaults, the Free-only GUI filter, the decode-rate column, and the i18n keys this delta actually adds. Triage gains a RUNTIME-CHECK disposition that must be resolved rather than labelled, requires PRE-EXISTING to prove the user-visible failure was reachable on the baseline rather than that a helper existed, and closes the BLOCK -> PRE-EXISTING downgrade route. --- .../040_triage_protocol.md | 26 +++-- .../050_lane_packets.md | 95 ++++++++++++++----- 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md b/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md index ee6a04a1d7..fcc64b9143 100644 --- a/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md +++ b/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md @@ -5,9 +5,11 @@ wp2 returns six lane reports. This is how they become a release decision. ## 1. Normalize Each lane return is split into individual findings. A finding is only admitted with an -exact `path:line` anchor or a literal command and its output. An unanchored assertion is -recorded as **unsubstantiated** and re-derived by the main session or dropped; it never -blocks and it never passes silently. +exact `path:line` anchor or a literal command and its output. An unanchored or misanchored +assertion is recorded as **unsubstantiated** and the main session **must** re-derive it +against the tree. Dropping it undecided is not an option: a real blocker described with a +wrong line number is still a real blocker, and the anchor rule exists to make triage cheap, +not to discard findings. Findings from different lanes that name the same defect are merged, keeping every anchor. @@ -20,11 +22,20 @@ exactly one disposition. | --- | --- | --- | | `BLOCK` | Matches a blocker clause | Must be fixed and landed on `dev` before promotion | | `SHIP` | Real but does not match a clause | Recorded here, filed as an issue if it deserves one, released as is | -| `PRE-EXISTING` | Present in `2f3f73629` as well | Not this release's problem; prove it with a command against the released tree | +| `PRE-EXISTING` | The same user-visible failure was reachable on `2f3f73629` | Not this release's problem; requires the proof below | +| `RUNTIME-CHECK` | Plausible but only decidable by running something | Must be resolved before promotion, by a targeted test, a CI job, or a reasoned rebuttal — never left as a confidence label | | `WRONG` | The lane misread the code | Rebutted with the anchor that disproves it | -A finding is `PRE-EXISTING` only with proof: `git show 2f3f73629:` showing the same -defect, or a test that fails on the baseline. "It looks old" is not proof. +A finding is `PRE-EXISTING` only when the **user-visible failure** was reachable on the +baseline — not merely that some function it touches already existed. Showing that an old +helper is unchanged proves nothing when a new caller reaches it under new conditions; +clause 3 exists precisely for that case. Acceptable proof is byte identity of every file on +the failure path (`git rev-parse 2f3f73629:` equal to `git rev-parse origin/dev:` +for each), a test that fails on the baseline, or a baseline CI run showing the same failure. + +`BLOCK` may never be downgraded to `SHIP`, and it may only become `PRE-EXISTING` under the +proof above. Weak-proof downgrade is the same evasion as reclassifying to `SHIP`, taken by a +longer route. ## 3. Remediate @@ -43,6 +54,9 @@ Landing a fix **moves the candidate**. When that happens: ## 4. Escalate rather than weaken +A `RUNTIME-CHECK` finding that cannot be resolved is treated as a `BLOCK`, not as a +`SHIP`. An unfalsified hang or teardown risk is not evidence of safety. + If a `BLOCK` cannot be fixed inside this scope — it needs a design decision, an external credential, or a change the user has not authorized — the release stops and the outcome is `BLOCKED`. Reclassifying a blocker to `SHIP` to reach a release is the one move this diff --git a/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md b/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md index e5d4b7c540..bb4706771a 100644 --- a/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md +++ b/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md @@ -7,57 +7,100 @@ They run concurrently because their questions are independent; none reads anothe Every packet carries the same frame, with only `SCOPE` and `QUESTIONS` differing. -- **Repository:** `/Users/jun/.codex/worktrees/b53a/opencodex`, branch - `codex/260910-250-regression-audit-release` at the freeze SHA. +- **Repository:** `/Users/jun/.codex/worktrees/b53a/opencodex`, on branch + `codex/260910-250-regression-audit-release`. That branch adds `devlog/` commits on top of + the freeze SHA `12c248f52`; every `src`, `gui`, `tests`, and `scripts` file is identical + to the freeze. **Do not `git checkout` the freeze SHA** — it would detach HEAD on the + worktree we are releasing from. Read `origin/dev` through `git show` if an exact freeze + read is needed. - **Comparison:** `git diff 2f3f73629...origin/dev -- `. `2f3f73629` is released `v2.49.0`; the right side is the 2.50.0 candidate. -- **MUST NOT:** no writes, edits, commits, pushes, stashes, or branch changes; no test - suite, typecheck, build, or install; no mutating `gh` call. Read-only `git` and - `gh api`/`gh run list` only. Do not fix anything found — report it. +- **Read the current tree, not only the diff.** A change is often half in the diff and half + in an unchanged caller. Following a symbol into a file outside the lane's diff is expected. +- **MUST NOT:** no writes, edits, commits, pushes, stashes, branch changes, or + `git checkout`/`git switch`/`git restore` of any kind; no test suite, typecheck, build, or + install; no mutating `gh` call. Read-only `git` and `gh api`/`gh run list` only. Do not + fix anything found — report it. - **PROOF:** every finding needs an exact `path:line` on the candidate side, or a literal - command with its output. Unanchored claims are discarded, so do not pad the return. + command with its output. Unanchored claims are re-derived by the main session, so an + approximate anchor costs a round trip rather than being silently dropped. - **RETURN FORMAT:** `VERDICT` (`NO-BLOCKER` or `BLOCKERS-FOUND`), then one numbered entry per finding with `ANCHOR`, `WHAT BREAKS` (the concrete user-visible failure and the input - that triggers it), `CLAUSE` (which of the eight blocker clauses, or `non-blocking`), and + that triggers it), `CLAUSE` (a number from the list below, or `non-blocking`), and `CONFIDENCE` (`certain` / `likely` / `needs-runtime-check`). Then `FILES READ`. - **DECISION BOUNDARY:** the lane reports evidence and unresolved judgments. It does not decide whether the release proceeds, does not rank against other lanes, and does not weaken a finding because it looks hard to fix. +### The eight blocker clauses, carried inline + +A lane cannot answer `CLAUSE` from a file it was not given, so the list travels with the +packet: (1) regression against 2.49.0; (2) crash, hang, or unbounded resource use on a +reachable default path; (3) new-path functional breakage — a feature added in this delta +that does not do what it claims, even though it is not a regression; (4) security or +privacy weakening; (5) user-consent or identity-spend bypass; (6) core invariant violation +(Lab reaching `src/router.ts`, `src/server/lifecycle.ts`, or `src/server/responses/core.ts`; +an `await` in the synchronous `startServer` activation window; a tracked gitlink); +(7) upgrade-path breakage for an existing 2.49.0 install; (8) broken release, packaging, +or operator-surface contract. + A lane that finds nothing returns `NO-BLOCKER` and its `FILES READ`. A short honest -return beats a long speculative one; the triage protocol discards unanchored text anyway. +return beats a long speculative one. ## Per-lane questions **L1 — responses and request pipeline.** Does the hosted web-search bridge arm only when opted in, and does a failure fall back rather than hang or leak? Is the search cell placed -in stream order, and are bridge continuations bounded? Does the non-streaming -context-overflow classification return a classified reply on every exhausted-target path? -Does agent-task recovery on a mid-thread model switch preserve encrypted content? Does the -configurable body admission limit still have a safe default and reject rather than buffer? +in stream order, and are bridge continuations bounded? Does `src/web-search/ollama-executor.ts` +bound its own errors and timeouts? Does the non-streaming context-overflow classification +return a classified reply on every exhausted-target path? Does agent-task recovery on a +mid-thread model switch preserve encrypted content? Does the configurable body admission +limit still have a safe default and reject rather than buffer? In `src/claude/inbound.ts`, +does emitting mid-conversation `role:"system"` as chronological `developer` items change +what the model obeys on an ordinary Claude Code turn? In `src/server/responses/codex-ws-wire.ts`, +what is the cost of the 30s to 90s prelude timeout when the upstream is actually hung? +Does the new `account` filter in `src/server/request-log.ts` match the value that is +actually stored, including when masking is on? Does the OpenCode Zen free-tier message +rewrite in `src/server/chat-native.ts` alter a paid-tier request? -**L2 — codex accounts, quota, OAuth.** Can a deferred validation leave an account in a -state where it is neither usable nor visibly failed? Does a revoked pool grant reach a -terminal verdict instead of retrying forever? Does clearing reauth state ever clear it for -the wrong account? Does the new account plan field ever carry a value that identifies the -user into a log or the wire? +**L2 — codex accounts, quota, OAuth.** Can a deferred validation leave an account neither +usable nor visibly failed? Does a revoked pool grant reach a terminal verdict instead of +retrying forever? Does clearing reauth state ever clear it for the wrong account? Does the +new account plan field ever carry a value that identifies the user into a log or the wire? +Does `src/oauth/health.ts` report healthy for an account that cannot actually serve? Does +the quota-header dual-write in `src/codex/quota.ts` ever attribute one account's window to +another? **L3 — catalog, providers, combos, config.** Does free-model classification ever mark a paid model free, or drop a model whose `pricingStatus` is absent rather than `"free"`? Does quota-exhausted inactive marking recover when quota returns? Does the AI Studio discovery restoration change behavior for custom gateways? Can a cross-provider blocked -model redirect cycle? +model redirect cycle? Does the keyless free-tier `MissingSessionID` rewrite in +`src/providers/opencode-zen-rate-limit.ts` mask a real auth failure? In `src/config.ts` and +`src/types/config.ts`, what do `privacy.maskEmails` and the inbound body limit resolve to +when the key is absent or malformed — does the schema degrade to a safe default or to +`undefined`? Does the `zcode` config export leak anything it did not before? **L4 — management API, service, GUI.** Does any management route lose its auth check? Does the routed-account label reach a response a browser can read without a session? Does the -launchd bootout recovery ever tear down a healthy job? Do the nine i18n locales contradict -`en` on a destructive action's confirmation text? - -**L5 — security, privacy, release surface.** Is email masking still on by default, and does -the opt-out require an explicit operator action? Does `bun run privacy:scan`'s contract -still hold for every new log site? Does any `src/lab/` module now reach `src/router.ts`, -`src/server/lifecycle.ts`, or `src/server/responses/core.ts`? Is there any `await` in the -synchronous `startServer` activation window? Is any gitlink tracked? +launchd bootout recovery ever tear down a healthy job? In `gui/src/pages/models-shared.ts` +and `Models.tsx`, can `freeOnlyInForce` stay true after the control disappears and leave +the user with an empty model list? Does the decode-rate column +(`src/server/management/shared.ts`, `gui/src/pages/Logs.tsx`) stay out of request history +as intended, and is the rate meaningful when the sample is tiny? Do the nine non-English +locales carry the keys this delta actually added — `models.freeOnly`, +`models.inactiveNoCredit`, `logs.detail.decodeTokPerSec`, +`pws.healthLabel.validationPending` — and does any translation invert the meaning of the +English source? Does the account-pool `validationPending` copy tell the operator what to do? + +**L5 — security, privacy, release surface.** Is email masking on by default in the resolved +config, and does the opt-out require an explicit operator action? Read every log call site +added in this delta and name any that can emit an address, token, request body, or account +identifier — static reading only, do not run the scan. Does any `src/lab/` module now reach +`src/router.ts`, `src/server/lifecycle.ts`, or `src/server/responses/core.ts` through any +import chain? Is there any `await` in the synchronous `startServer` activation window in +`src/server/index.ts`, and does the bind-time `maxRequestBodySize` wiring there agree with +the configured limit and its default? Is any gitlink tracked? **L6 — operator CLI surface.** Does `ocx status` apply the same masking default as the library? Does `ocx account refresh` mint or reuse a dashboard session, and does it spend From b8f6db2a49be385fce45b682a557f61d463b06d6 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 18:44:30 +0900 Subject: [PATCH 008/126] docs(devlog): record the six-lane regression audit result for 2.50.0 All six lanes returned NO-BLOCKER against the eight blocker clauses. Eight non-blocking findings are recorded with dispositions, including the one RUNTIME-CHECK item, resolved by tracing pricingStatus to its only two consumers and confirming it gates no routing and no spend. The single CI failure is dispositioned PRE-EXISTING on byte identity: the failing test, its subject, and its helper are the same blobs at 2f3f73629 as on dev, so nothing in this delta can have caused a 60s timeout that took 112.8s on a Windows runner. Also records the checks the main session re-derived itself rather than accepting on a lane's authority. --- .../030_evidence.md | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md index 761f26e126..3961233074 100644 --- a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md +++ b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md @@ -46,9 +46,44 @@ key-exchange bounds, which are not in this delta. ## Audit findings (wp2) -| ID | Lane | File:line | Failure mode | Blocker | Disposition | -| --- | --- | --- | --- | --- | --- | -| _pending_ | | | | | | +Six `xai/grok-4.6` lanes, dispatched in one round, fresh context each, read-only. +**All six returned `NO-BLOCKER`.** No finding matched any of the eight blocker clauses. + +| Lane | Agent | Verdict | Files read | +| --- | --- | --- | --- | +| L1 responses / web-search | `01a08aa6-5954-76f1-a205-f4a85b76457f` | NO-BLOCKER | 31 | +| L2 accounts / quota / OAuth | `01a08aa6-59f5-79d3-a567-556a80d05c84` | NO-BLOCKER | 23 | +| L3 catalog / providers / config | `01a08aa6-5aa1-7132-b053-776bb02b0fe7` | NO-BLOCKER | 36 | +| L4 management / service / GUI | `01a08aa6-5b57-7343-9e1c-b4b3ea186478` | NO-BLOCKER | 41 | +| L5 security / privacy / release | `01a08aa6-5c11-7c52-b4db-6ec685159a33` | NO-BLOCKER | 40 | +| L6 operator CLI | `01a08aa6-5ccd-76c0-af89-83cf0ea80e28` | NO-BLOCKER | 34 | + +### Non-blocking findings, with dispositions + +| ID | Lane | Anchor | What it is | Disposition | +| --- | --- | --- | --- | --- | +| F1 | L1 | `src/web-search/passthrough-bridge.ts:503` | If the upstream emits a `web_search` function call and then `response.failed`/`incomplete`, `decide()` ends without `searchEndFrames`, so a client can keep a "Searching the web" cell open under a failed turn. The explicit `kind === "fail"` path does close it. Opt-in bridge only, default off. | `SHIP` — cosmetic, on a feature that must be explicitly enabled | +| F2 | L3 | `src/codex/catalog/provider-fetch.ts:1415` | Classification reads `pricing.prompt`/`completion` only, so a row with both at zero plus a paid `pricing.request`/`image`/`web_search` key would classify `free`. No in-tree fixture has that shape. | `SHIP` — `RUNTIME-CHECK` resolved: `pricingStatus` is consumed only by `src/cli/models-runtime.ts:68` and `gui/src/pages/models-shared.ts:85` as a display filter. It gates no routing and no spend, so the worst case is a mislabelled row, not a charge | +| F3 | L3 | `src/codex/catalog/provider-fetch.ts:1996` | A custom google-adapter gateway returning both `data[]` and a non-Google `models[]` would take the AI Studio parser with zero `generateContent` rows and publish an authoritative empty catalog. The `data[]`-only case is covered by `tests/adapters/google/google-models-listing.test.ts`. | `SHIP` — requires a dual-envelope body no known gateway sends | +| F4 | L2 | `src/oauth/token-guardian.ts:257` vs `src/codex/auth-api.ts:1265` | `isCodexAccountUsable` does not read the persisted terminal flag, so after a restart routing can attempt a dead grant once more. | `PRE-EXISTING` — same process-lifetime pattern as 2.49.0; the guardian that writes the flag is opt-in and default off | +| F5 | L2, L5 | `src/oauth/health.ts:231` | A revoked grant with no persisted terminal and no in-memory reauth can still project healthy after a restart when the guardian never ran. | `PRE-EXISTING` — 2.49.0 behavior; 2.50.0 only adds the `validation_pending` projection, which is strictly more informative | +| F6 | L4 | `src/service.ts:2386` | Install/repair bootout evicts the loaded job, including one that is currently serving, after the plist has been rewritten. | `SHIP` — this is the intended #4141 repair; `startLaunchd` at :2422 still refuses that eviction on the ordinary start path | +| F7 | L6 | `src/cli/capabilities.ts` | `ocx models live --free-only` is a real new flag that is not a declared capability, so it does not reach the generated surface map. | `SHIP` — documentation gap, not the map/registry split that once shipped a phantom `ocx request-history` | +| F8 | CI | `tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts` | `classifies continuous progress stopped by MAX_ITERATIONS as bounded work` timed out at 60s on Windows shard 5/6 of run 34457689927 attempt 1, taking 112.8s. | `PRE-EXISTING` — proved by byte identity against the released tree: `src/codex/log-guard/maintenance.ts` (`81b3a465b`), the test file (`54e83bba2`), and `tests/helpers/remove-tree.ts` (`53e36a584`) are the same blobs at `2f3f73629` and `origin/dev`. Nothing in this delta can have caused it | + +### Independent re-derivation by the main session + +Nothing was accepted on a lane's authority. Re-checked directly: + +| Claim | Command | Result | +| --- | --- | --- | +| Email masking on by default | `rg -n maskEmails src/lib/privacy.ts` | `config?.privacy?.maskEmails !== false` — absent, malformed, and non-boolean all mask | +| Inbound body limit safe default | `rg -n MAX_DECOMPRESSED_BODY_BYTES src/server/request-decompress.ts` | 256 MiB, returned when the configured value is undefined | +| No Lab import in the three core files | `rg -n 'from "[./]*lab/' src/router.ts src/server/lifecycle.ts src/server/responses/core.ts` | no match | +| `startServer` still synchronous | `rg -n 'function startServer' src/server/index.ts` | `export function startServer(...): Server` — not `async` | +| No tracked gitlink | `git ls-files -s \| grep -c '^160000'` | 0; `.gitmodules` absent | +| i18n keys in every locale | per-locale `rg -c` for the four new keys | 6 matches in each of en, ko, de, fr, ja, ru, tr, zh, zh-TW | +| Web-search bridge opt-in | `rg -n webSearchBridge src/` | armed only by `providers..webSearchBridge.enabled` | ## Release artifacts (wp4) From aa3dfcc7d38b2fcb3b8c4e7ea8d8a4e2001b3fe2 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 18:53:35 +0900 Subject: [PATCH 009/126] docs(devlog): record the 2.50.0 release-readiness decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit produced no blockers, so wp3 has nothing to remediate and becomes a decision record. States what the decision rests on, why the Windows Log Guard timeout is not being fixed first — hardening it would move the freeze SHA and void the audit for a test 2.49.0 shipped with the same bytes and the same limit — and records the three limits the fourth review round named, including that every lane was a static reader and that the re-derivation table checks invariants rather than re-answering the packet questions. Completes the F8 byte-identity proof with tests/preload.ts, the one file on that failure path the delta touches, whose diff is comments only. --- .../030_evidence.md | 8 ++- .../060_release_readiness.md | 63 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md index 3961233074..942ac8bf96 100644 --- a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md +++ b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md @@ -71,6 +71,11 @@ Six `xai/grok-4.6` lanes, dispatched in one round, fresh context each, read-only | F7 | L6 | `src/cli/capabilities.ts` | `ocx models live --free-only` is a real new flag that is not a declared capability, so it does not reach the generated surface map. | `SHIP` — documentation gap, not the map/registry split that once shipped a phantom `ocx request-history` | | F8 | CI | `tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts` | `classifies continuous progress stopped by MAX_ITERATIONS as bounded work` timed out at 60s on Windows shard 5/6 of run 34457689927 attempt 1, taking 112.8s. | `PRE-EXISTING` — proved by byte identity against the released tree: `src/codex/log-guard/maintenance.ts` (`81b3a465b`), the test file (`54e83bba2`), and `tests/helpers/remove-tree.ts` (`53e36a584`) are the same blobs at `2f3f73629` and `origin/dev`. Nothing in this delta can have caused it | +F8 note: `tests/preload.ts` is the one file on that failure path this delta does touch, +and its diff is a comment block with no statement change, so the byte-identity argument is +complete rather than merely true. Attempt 2 of the run passed on rerun. The full reasoning, +including why the timeout is not hardened before the release, is in `060_release_readiness.md`. + ### Independent re-derivation by the main session Nothing was accepted on a lane's authority. Re-checked directly: @@ -89,7 +94,8 @@ Nothing was accepted on a lane's authority. Re-checked directly: | Gate | Evidence | Status | | --- | --- | --- | -| Candidate-tree CI (`dev` dispatch, audit evidence) | run 34457689927 on `12c248f52` | pending | +| Candidate-tree CI (`dev` dispatch, audit evidence only) | run 34457689927, `lane=all` on `12c248f52`, attempt 2 conclusion `success` | done | +| Freeze tree to reproduce on `main` | `git rev-parse 12c248f52^{tree}` = `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | recorded | | `dev` pre-move to 2.51.0 | | pending | | `main` promotion merge SHA | | pending | | Push-event Cross-platform CI on merge SHA | | pending | diff --git a/devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md b/devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md new file mode 100644 index 0000000000..37f429193b --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md @@ -0,0 +1,63 @@ +# Release-readiness decision (wp3) + +wp3 was scoped to triage and remediate release-blocking findings. **The audit produced +none**, so there is nothing to remediate and this cycle is a decision record instead. + +## The decision + +Promote and publish freeze SHA `12c248f52bed88ea13be5b284c79a238feb592d1` as 2.50.0. + +## What the decision rests on + +| Evidence | Detail | +| --- | --- | +| Six independent lanes | All returned `NO-BLOCKER` against the eight clauses, 23-41 files read each, covering all 94 changed product paths | +| Candidate-tree CI | Run `34457689927`, `ci.yml` with `lane=all` on `12c248f52`, attempt 2 conclusion **success** | +| Focused local suites | 84 pass / 0 fail across the web-search bridge, Lab/core boundary, privacy masking, `skills/ocx` surface, body-size limit, live service-manager guard, and context-overflow | +| Main-session re-derivation | Seven invariants re-checked directly rather than accepted from a lane | +| Independent decision audit | Reviewer round 4 returned **GO** and confirmed the release sequence has no defects | +| Freeze tree | `git rev-parse 12c248f52^{tree}` = `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822`, the value the promotion merge must reproduce | + +## The CI flake, and why it is not being fixed first + +Attempt 1 of run `34457689927` failed one job. One test — +`CodeRabbit Log Guard reclaim regressions > classifies continuous progress stopped by +MAX_ITERATIONS as bounded work` — exceeded the suite-wide `--timeout 60000` after taking +112,853.92 ms on Windows shard 5/6. Everything else passed: 4058 pass, 15 skip, 1 fail. + +Every file on that failure path is byte-identical to the released 2.49.0 tree: + +| File | Blob at `2f3f73629` and at `origin/dev` | +| --- | --- | +| `src/codex/log-guard/maintenance.ts` | `81b3a465b5dbddc11c7431b99fec52012b61cf65` | +| `tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts` | `54e83bba2a62b9fffd39f88839f3c339e1c26080` | +| `tests/helpers/remove-tree.ts` | `53e36a584c627b75a3c3b58a28e2bd17d7636b8b` | + +`tests/preload.ts` is the one file on that path the delta does touch, and the change is +a comment block only — no statement changed. The round-4 reviewer caught that the first +version of this proof enumerated three blobs and called it "every file on the failure +path"; the diff is recorded here so the claim is complete rather than merely true. + +Rerunning the failed job produced attempt 2 with conclusion `success`, which also +demonstrates the mechanic the release gate depends on: `release.yml` searches +`gh run list --workflow ci.yml --commit "$GITHUB_SHA" --event push` and reads the run's +conclusion, and a rerun updates that conclusion in place. + +Hardening the timeout would move the freeze SHA, void this audit, and reopen every gate +for a test that 2.49.0 already shipped with the same bytes and the same limit. The +mitigation is the rerun, applied again on the promotion merge if it recurs. + +## Recorded limits of the audit + +The round-4 reviewer named three, and they are recorded rather than argued away. + +1. **Every lane was a static reader.** `NO-BLOCKER` means no clause matched a read, not + that the new SSE bridge cannot hang at runtime. The 84 focused tests and the full + `lane=all` CI run are what cover the dynamic half; the lane verdicts alone are not. +2. **The re-derivation table checks invariants, not the packet questions.** It confirms + masking, the body limit, the Lab boundary, `startServer`, gitlinks, i18n keys, and the + bridge opt-in. It does not independently re-answer the inbound `developer` remap, the + 90-second WS prelude, or `freeOnlyInForce`; those rest on the lane read plus CI. +3. **Treating an unresolved `RUNTIME-CHECK` as a `BLOCK` creates pressure to under-report + it.** Exactly one finding carried that label and it was resolved by tracing consumers. + A lane that quietly downgrades rather than raising the label would not be visible here. From 2cf35c840a72f7a7a8cef578837d9adbffbfec40 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 18:54:32 +0900 Subject: [PATCH 010/126] docs(devlog): write the 2.50.0 execution runbook The pre-written decade doc wp4 consumes. Fixes the freeze SHA and freeze tree as inputs, gives each step the value it must record, and makes tree equality a stop condition rather than an expectation to adjust. States the Windows Log Guard rerun as the recorded mitigation so it is not improvised under release pressure, and keeps the never-republish-on-a-timed-out-smoke rule next to the verification commands where it is actually needed. --- .../070_release_execution.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 devlog/_plan/260910_250_regression_audit_release/070_release_execution.md diff --git a/devlog/_plan/260910_250_regression_audit_release/070_release_execution.md b/devlog/_plan/260910_250_regression_audit_release/070_release_execution.md new file mode 100644 index 0000000000..d5c03912cb --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/070_release_execution.md @@ -0,0 +1,103 @@ +# 2.50.0 execution runbook (wp4) + +The exact sequence, with the value each step must record. `020_release_plan.md` says why; +this says what to run. Every SHA below is written down as it is produced, because the next +step verifies against it rather than against "current". + +## Fixed inputs + +| Name | Value | +| --- | --- | +| Freeze SHA | `12c248f52bed88ea13be5b284c79a238feb592d1` | +| Freeze tree | `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | +| Version | `2.50.0` | +| Previous release | `v2.49.0` at `main` `2f3f736299dca38861f8fb9c4326a4b4d7c664bc` | +| Default branch | `main` | + +## Step 1 — pre-move `dev` + +```sh +gh workflow run dev-version-bump.yml --ref main \ + -f intended-version=2.50.0 -f mode=pre-move +``` + +The workflow opens a pull request; it cannot push to `dev` because the `Protect dev` +ruleset requires review. Merge that PR, then confirm: + +```sh +git fetch origin dev +git show origin/dev:package.json | head -3 # must read 2.51.0 +``` + +Record: the bump PR number and the merged `dev` SHA. + +Why this is first: `release.yml:242-249` runs +`bun scripts/version-line.ts assert-ahead 2.50.0`, which fails while `dev` +is still 2.50.0. Doing it after the promotion would strand a published-but-refused release. + +## Step 2 — promote the freeze SHA to `main` + +```sh +git fetch origin main +git switch -c codex/release-250-main origin/main +git merge --no-ff 12c248f52 -m "release: promote verified 2.50.0 product tree to main" +git rev-parse HEAD^{tree} # must equal d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822 +``` + +If the tree does not match, a conflict resolution changed the product and the audit no +longer describes what would ship. Stop and re-derive rather than adjusting the expectation. + +Open the PR into `main` using `.github/PULL_REQUEST_TEMPLATE.md`, merge it, then: + +```sh +git fetch origin main +git rev-parse origin/main # record as MERGE_SHA +git rev-parse origin/main^{tree} # must still equal the freeze tree +``` + +Record: the promotion PR number, `MERGE_SHA`, and the confirmed tree. + +## Step 3 — wait for the release-branch gates on `MERGE_SHA` + +Both fire automatically on the merge push — `ci.yml` because `main` is in its push +branches and `src/**`/`gui/**` changed, `service-lifecycle.yml` because `package.json` and +`src/cli/index.ts` are in its push paths. + +```sh +gh run list --workflow ci.yml --commit "$MERGE_SHA" --event push --json conclusion,url +gh run list --workflow service-lifecycle.yml --commit "$MERGE_SHA" --json conclusion,url +``` + +Both must reach `success`. If Windows shard 5/6 times out on the Log Guard reclaim test +again, rerun that job in place with `gh run rerun --failed`; the gate reads the run's +conclusion, which a rerun updates. That is the recorded mitigation, not an improvisation. + +## Step 4 — dry run, then publish + +```sh +gh workflow run release.yml --ref main \ + -f version=2.50.0 -f tag=latest -f expected-sha="$MERGE_SHA" -f dry-run=true +``` + +A dry run still executes `prepublishOnly` (typecheck plus the GUI build), so a green dry +run is real evidence about the package, not a formality. Only then: + +```sh +gh workflow run release.yml --ref main \ + -f version=2.50.0 -f tag=latest -f expected-sha="$MERGE_SHA" -f dry-run=false +``` + +## Step 5 — verify the artifacts independently + +```sh +npm view @bitkyc08/opencodex dist-tags --json +npm view @bitkyc08/opencodex@2.50.0 version gitHead dist.integrity --json +git ls-remote --tags origin | grep v2.50.0 +gh release view v2.50.0 --json tagName,isDraft,isPrerelease,createdAt +``` + +`gitHead` must equal `MERGE_SHA`. npm propagation lag shows a 404 or a stale `latest` +for a while; poll. **Never republish because a smoke step timed out** — inspect metadata, +provenance, and the tarball first, because npm may already have accepted the publish. + +Record every value into the release-artifacts table in `030_evidence.md`. From af190effa9c454150959f9d55ff5bfa739c99623 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 19:14:42 +0900 Subject: [PATCH 011/126] docs(devlog): record the 2.50.0 promotion through main dev pre-moved to 2.51.0 via #4194, and #4195 promoted freeze SHA 12c248f52 into main as 2d4d7a223 with a tree byte-identical to the audited freeze tree. Records why enforce-target failed by design on a promotion PR, with the 2.49.0 precedent, and why local prepush was skipped on a tree already green on three platforms. --- .../030_evidence.md | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md index 942ac8bf96..6fcbe512bd 100644 --- a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md +++ b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md @@ -96,12 +96,27 @@ Nothing was accepted on a lane's authority. Re-checked directly: | --- | --- | --- | | Candidate-tree CI (`dev` dispatch, audit evidence only) | run 34457689927, `lane=all` on `12c248f52`, attempt 2 conclusion `success` | done | | Freeze tree to reproduce on `main` | `git rev-parse 12c248f52^{tree}` = `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | recorded | -| `dev` pre-move to 2.51.0 | | pending | -| `main` promotion merge SHA | | pending | -| Push-event Cross-platform CI on merge SHA | | pending | -| Service lifecycle on merge SHA | | pending | +| `dev` pre-move to 2.51.0 | `dev-version-bump.yml` run 34463313646 opened PR #4194; merged; `origin/dev` = `cf44f6fe887d19f53ede1e09abfe0fe3cf137059`, `package.json` 2.51.0 | done | +| Promotion commit | `3a3de889b6ef3217497f6c5029acf08aec09c0cf`, parents `2f3f73629` (old `main`) and `12c248f52` (freeze), tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | done | +| `main` promotion merge SHA | PR #4195 merged; `origin/main` = `2d4d7a22381a2e497c2442902104619e25f937c7`, tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822`, version 2.50.0 | done | +| Push-event Cross-platform CI on merge SHA | run 34464454730 | pending | +| Service lifecycle on merge SHA | run 34464454609, conclusion `success` | done | | `release.yml` dry run | | pending | | `release.yml` publish | | pending | | npm `latest` = 2.50.0 | | pending | | `gitHead` matches promoted `main` | | pending | | git tag + GitHub release | | pending | + +### Gates that failed by design on the promotion PR + +`enforce-target` failed #4195 with "wrong base (main); missing UI screenshot". That gate is +written for contributor pull requests: `main` receives only release promotions, and a +promotion necessarily carries dashboard files while changing no UI of its own. The 2.49.0 +promotion PR #4117 failed the same check and was merged the same way. `AGENTS.md` records +the maintainer promotion exception, and the gates that actually decide are the push-event +runs on the merge SHA, which `release.yml` independently requires. + +Local `prepush` was skipped on the promotion branch. It runs the full ~850-file suite +against a tree byte-identical to one already green on Linux, macOS, and Windows +(`lane=all` run 34457689927), and it was additionally blocked waiting on another Bun test +lock. The remote push-event runs on `2d4d7a223` are the evidence that counts. From aeeddfc24f611a32b08967a8a56d80b789fec6f1 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 19:31:17 +0900 Subject: [PATCH 012/126] docs(devlog): record the verified 2.50.0 release artifacts npm latest is 2.50.0 with gitHead 2d4d7a223 matching the promoted main SHA, the tag and GitHub release point at that same commit, the downloaded tarball hashes to the registry's declared sha512, and three audited source files inside it are byte-identical to the released tree. Provenance carries both the npm publish attestation and SLSA v1. Records that the post-publish registry smoke timed out after npm had already accepted the publish, that the registry served the version about twenty minutes later, and that nothing was republished. Also records why preview stays on its own version line. --- .../030_evidence.md | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md index 6fcbe512bd..5d70374aea 100644 --- a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md +++ b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md @@ -99,13 +99,37 @@ Nothing was accepted on a lane's authority. Re-checked directly: | `dev` pre-move to 2.51.0 | `dev-version-bump.yml` run 34463313646 opened PR #4194; merged; `origin/dev` = `cf44f6fe887d19f53ede1e09abfe0fe3cf137059`, `package.json` 2.51.0 | done | | Promotion commit | `3a3de889b6ef3217497f6c5029acf08aec09c0cf`, parents `2f3f73629` (old `main`) and `12c248f52` (freeze), tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | done | | `main` promotion merge SHA | PR #4195 merged; `origin/main` = `2d4d7a22381a2e497c2442902104619e25f937c7`, tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822`, version 2.50.0 | done | -| Push-event Cross-platform CI on merge SHA | run 34464454730 | pending | +| Push-event Cross-platform CI on merge SHA | run 34464454730, conclusion `success` | done | | Service lifecycle on merge SHA | run 34464454609, conclusion `success` | done | -| `release.yml` dry run | | pending | -| `release.yml` publish | | pending | -| npm `latest` = 2.50.0 | | pending | -| `gitHead` matches promoted `main` | | pending | -| git tag + GitHub release | | pending | +| `release.yml` dry run | run 34465317829, `validate-dispatch` and `publish` both `success` | done | +| `release.yml` publish | run 34465442114, `dry-run=false`, `expected-sha=2d4d7a223`; `npm publish --tag latest --access public` printed `+ @bitkyc08/opencodex@2.50.0` | done | +| npm `latest` = 2.50.0 | `npm view @bitkyc08/opencodex dist-tags` -> `{"preview":"2.48.0-preview.20260908","latest":"2.50.0"}` | done | +| `gitHead` matches promoted `main` | `npm view @bitkyc08/opencodex@2.50.0 gitHead` = `2d4d7a22381a2e497c2442902104619e25f937c7`, identical to `origin/main` | done | +| git tag + GitHub release | `git rev-list -n1 v2.50.0` = `2d4d7a223`; release `v2.50.0` published 2026-09-10T10:21:15Z, not a draft, not a prerelease | done | +| Tarball integrity | Downloaded tarball hashes to `sha512-lrcM1sBfjbjqB3h5i2q7A6FbPOXxrdxqhWC7S+w0+oCOZ+9f8ucCgXPt9D2p81dS78ZfYYSJZuDWbU1Ov0VOhQ==`, equal to `dist.integrity`; manifest version 2.50.0; 1094 files, 23,923,744 bytes unpacked | done | +| Published source bytes | `src/lib/privacy.ts`, `src/web-search/passthrough-bridge.ts`, and `src/cli/models-runtime.ts` inside the tarball are SHA-256 identical to the same paths at `2d4d7a223` | done | +| Provenance | Registry attestations are `npm/attestation/tree/main/specs/publish/v0.1` and `slsa.dev/provenance/v1` | done | + +### The registry smoke timed out, and why nothing was republished + +`npm publish` printed `+ @bitkyc08/opencodex@2.50.0` at 10:20:46, and the workflow's +own `Post-publish registry smoke` then failed to read the version back through six bounded +attempts over roughly 27 seconds. It emitted +"npm publish succeeded, but registry verification remains pending; continuing GitHub +release creation without republishing" and proceeded, which is the correct behavior: the +publication receipt already existed. + +The registry served 2.50.0 about 20 minutes after the publish. It was polled, never +republished. This is the documented failure mode — a timed-out availability smoke is not a +failed publish, and republishing on it is how a release gets damaged. + +### `preview` is intentionally not part of this release + +`origin/preview` remains `2.49.0-preview.20260909` and the npm `preview` dist-tag remains +`2.48.0-preview.20260908`. `release.yml:161-165` refuses a preview publish whose version is +not `*-preview.*`, so promoting the plain 2.50.0 tree onto that branch would break its +version line. Bringing `preview` forward needs its own `2.50.0-preview.` commit and +is a separate decision. ### Gates that failed by design on the promotion PR From 188d925e7e04290343ed34345b5426b856c6a1cb Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 19:32:09 +0900 Subject: [PATCH 013/126] docs(devlog): close the 2.50.0 unit with the delivery record One table from the released baseline through the published tarball, so the chain can be re-walked without the transcript. Records that the audited freeze tree, the promotion commit tree, and the merged main tree are the same object and that the published source bytes match it, names what the seven subagent runs actually bought, and states what is deliberately left unfinished: preview's version line, the seven unfiled non-blocking findings, and the Windows Log Guard timing that predates this release. --- .../080_delivery_record.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md diff --git a/devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md b/devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md new file mode 100644 index 0000000000..43f3996610 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md @@ -0,0 +1,42 @@ +# Delivery record — 2.50.0 + +Published 2026-09-10. `@bitkyc08/opencodex@2.50.0` is the npm `latest`. + +## The chain, end to end + +| # | What | Value | +| --- | --- | --- | +| 1 | Released baseline | `v2.49.0`, `main` `2f3f736299dca38861f8fb9c4326a4b4d7c664bc` | +| 2 | Audited freeze SHA | `12c248f52bed88ea13be5b284c79a238feb592d1`, tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | +| 3 | Candidate CI | `ci.yml` `lane=all` run 34457689927, success | +| 4 | `dev` pre-move | run 34463313646 -> PR #4194 -> `dev` `cf44f6fe887d19f53ede1e09abfe0fe3cf137059` at 2.51.0 | +| 5 | Promotion commit | `3a3de889b6ef3217497f6c5029acf08aec09c0cf` | +| 6 | `main` merge SHA | PR #4195 -> `2d4d7a22381a2e497c2442902104619e25f937c7`, tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | +| 7 | Release-branch gates | `ci.yml` 34464454730 success, `service-lifecycle.yml` 34464454609 success | +| 8 | Dry run | `release.yml` 34465317829 success | +| 9 | Publish | `release.yml` 34465442114 success | +| 10 | Registry | `latest` = 2.50.0, `gitHead` = `2d4d7a223`, tarball sha512 matches `dist.integrity`, SLSA v1 provenance present | +| 11 | Tag and release | `v2.50.0` -> `2d4d7a223`, GitHub release published, not a draft | + +**One tree throughout.** The audited freeze tree, the promotion commit's tree, and the +merged `main` tree are the same object, `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822`, and +three audited source files inside the published tarball hash identically to that tree. What +shipped is what was read. + +## What the audit cost and produced + +Seven `xai/grok-4.6` subagent runs: one standing reviewer across four rounds, and six +concurrent audit lanes. The reviewer failed the first roadmap outright, and that was the +most valuable moment in the whole unit — it caught that the release order did not match +what `release.yml` gates on, that promoting the 2.50.0 tree onto `preview` would have +broken that branch's version line, and that four `src/cli` files had no lane. The lanes +then returned no blockers, and the fourth round audited the release decision rather than +the code and returned GO. + +## What is deliberately unfinished + +- `preview` stays at `2.49.0-preview.20260909`, npm `preview` at `2.48.0-preview.20260908`. + It needs its own `2.50.0-preview.` commit, which is a separate decision. +- Seven non-blocking findings (F1-F7) are recorded but not filed as issues. +- The Windows Log Guard reclaim test remains able to exceed the 60s suite limit on a slow + runner. It is unchanged since 2.49.0; the mitigation is a job rerun, exercised twice here. From 39409f9e811dc6f253a2f6bb9b92287bf2a6e73d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:05 +0900 Subject: [PATCH 014/126] docs(devlog): plan the 260911 lane dispatch round --- .../260911_lane_dispatch_round/000_plan.md | 68 +++++++ .../010_lane_partition.md | 53 +++++ .../020_lane_packets.md | 181 ++++++++++++++++++ .../260911_lane_dispatch_round/060_ledger.md | 17 ++ 4 files changed, 319 insertions(+) create mode 100644 devlog/_plan/260911_lane_dispatch_round/000_plan.md create mode 100644 devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md create mode 100644 devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md create mode 100644 devlog/_plan/260911_lane_dispatch_round/060_ledger.md diff --git a/devlog/_plan/260911_lane_dispatch_round/000_plan.md b/devlog/_plan/260911_lane_dispatch_round/000_plan.md new file mode 100644 index 0000000000..715c18bca2 --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/000_plan.md @@ -0,0 +1,68 @@ +# Lane dispatch round — 260911 + +Freeze: `origin/dev` `6d3ad12e3` (package.json 2.51.0). Every lane branch is cut from that SHA. + +## Why this round exists + +`dev` did not move for 24 hours while 80 pull requests stayed open. The blocking pattern is not +reviewer capacity alone: three of the most urgent PRs (#4184, #4181, #4203) sat at +`CHANGES_REQUESTED`, and four more (#4188, #4174, #4171, #4210) were reset to draft by the +readiness gate after a push. Waiting on those forks does not land anything this round. + +## What decides the lane boundaries + +File ownership, not topic. `010_lane_partition.md` records the measured collisions across 27 open +PRs. Two facts fix the shape of this round: `src/server/responses/core.ts` is contended by four +open PRs and `src/providers/quota.ts` by another four, while twelve PRs touch no file any other +PR touches. Lanes are cut so that no two lanes own the same file, which is what makes unlimited +parallel agents useful rather than a rebase generator. + +## Decision-free filter + +This round dispatches only work whose expected behaviour is already fixed by a filed issue, so no +lane has to invent a maintainer policy. An item is in when the issue states the expected result and +no competing design is open; it is out when landing it would decide a policy the maintainer has not +decided. + +Excluded on purpose, with the decision that blocks each one: + +- #4213 — whether unknown native-surface endpoints are forwarded upstream or keep returning 404 is a + proxy policy decision, and the issue explicitly asks for it. +- #4198, #4179 — publishing an official container image changes a documented policy + ("opencodex does not publish an official container image"). +- #4173 — the atomic update design competes with #4185 and #4203 already in flight. +- Contributor feature PRs (#4183, #4100, #4111, #4193, #4033, #4042) — these need review, not + reimplementation, and reimplementing them would discard the author's work. + +## Roles + +The orchestrator thread owns the round: it holds the host goal, tracks every lane, refreshes live PR +and CI state, and performs merges one at a time. Lane threads own implementation inside their file +territory and stop at a green PR; they never merge and never touch another lane's files. + +## Execution rules carried from earlier rounds + +These are not new. They are the rules this repository's earlier parallel rounds ran under, and they +are repeated inside every packet so a lane thread that never reads this file still obeys them. + +1. **No local product suite.** No `bun test`, no `bun run test`, no `bun run typecheck`, no build, no + install. Report those checks as `NOT RUN` and bind confidence to hosted CI. Reading source and + running read-only `git`/`gh` is not a suite run. +2. **Push with `--no-verify`**, and prefix every mutating git command with + `git -c core.hooksPath=/dev/null` — this repository's hooks can start a GUI install, typecheck, + and build, which rule 1 forbids. +3. **Ordinary dependent PRs.** The first PR of a lane targets `dev`; a child targets its parent's + head branch, and is retargeted to `dev` after the parent lands. No native GitHub stacks. +4. **Attribution.** Carrying, superseding, or reimplementing another author's work requires a + `Co-authored-by` trailer naming that author in a branch commit, not prose. +5. **Final-head CI is the proof.** Green on an older head, a cancelled run, or a skipped job is not + passing evidence. The exact pushed SHA must be the one that is green. +6. **Unlimited `xai/grok-4.6` subagents**, read-only. They verify, reproduce, and audit; they do not + write files, and no finding enters a lane's work without a `path:line` anchor. + +## Merge policy + +Merges are serialized through the orchestrator because `dev` is protected and shared. A lane PR +merges when its exact head is green on final-head CI; the landing is proven by fetching `origin/dev` +and checking ancestry, never by the merge command's own output. + diff --git a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md new file mode 100644 index 0000000000..d61cc1bbda --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md @@ -0,0 +1,53 @@ +# Lane partition — measured file collisions + +Method: `gh pr view --json files` over 27 open PRs on 2026-09-11, `devlog/` paths dropped, then +grouped by path. This is a snapshot; a lane that finds a new collision reports it rather than +working around it silently. + +## Contended files + +| Count | Path | Open PRs | +|---|---|---| +| 4 | `src/server/responses/core.ts` | #4050, #4118, #4181, #4184 | +| 4 | `src/providers/quota.ts` | #4090, #4105, #4174, #4210 | +| 4 | `tests/providers/provider-quota.test.ts` | #4090, #4105, #4174, #4210 | +| 3 | `gui/src/i18n/{de,en,fr,ja,ko,ru,tr,zh,zh-TW}.ts` | #4111, #4183, #4193 | +| 3 | `scripts/test-layout/layout.json` | #4119, #4193, #4203 | +| 3 | `tests/fixtures/test-layout-expected.json` | #4119, #4193, #4203 | +| 2 | `src/server/claude-messages.ts` | #4050, #4184 | +| 2 | `src/server/chat-completions.ts` | #4118, #4184 | +| 2 | `src/types/tools.ts` | #4171, #4181 | +| 2 | `src/combos/resolve.ts` | #4090, #4105 | +| 2 | `src/config.ts` | #4100, #4183 | +| 2 | `src/update/job.ts` | #4185, #4203 | + +Collision-free PRs, touching no file any other open PR touches: #4062, #4104, #4119, #4124, #4130, +#4139, #4159, #4177, #4178, #4187, #4188, #4199. + +The two `test-layout` rows are the interesting ones. #4119, #4193, and #4203 have nothing to do with +each other and still collide, because every new test file has to be registered in both. The way out +is naming, not coordination: a test file named `tests//.test.ts` is placed by the regex +seeds in `layout.json` and needs no entry at all. + +## Lanes + +| Lane | Branch | File territory | Stack order | +|---|---|---|---| +| L1 | `codex/260911-l1-responses-core` | `src/server/responses/*`, `src/server/chat-completions.ts`, `src/server/claude-messages.ts`, `src/providers/opencode-go-transport.ts`, `src/types/tools.ts`, `src/responses/code-mode-helper-compat.ts` | #4172 → #4176 | +| L2 | `codex/260911-l2-catalog-provider` | `src/providers/quota*.ts`, `src/codex/catalog/*`, BigModel provider preset | #4201 → #4207 | +| L3 | `codex/260911-l3-account-pool` | `src/codex/account-*.ts`, `plan*.ts`, `warmup.ts`, `model-entitlements.ts` | #4126 → #4212 → #4211 | +| L4 | `codex/260911-l4-service-cli` | `src/update/*`, `src/service*.ts`, `src/cli/*`, stop/ownership refusal paths | #4202 → #4169 → #4204 | +| L5 | `codex/260911-l5-integrations-io` | `src/config/atomic-write.ts`, `src/integrations/*` | #4197 → #4214 | +| L6 | `codex/260911-l6-streaming-tools` | streaming/WebSocket prelude paths, vendor scaffolding filters | #4191 → #4190 | +| L7 | `codex/260911-l7-docs` | `docs-site/**` only | #4215 → #4200 | + +## Custody of shared assets + +Three assets are owned by nobody in this round and would otherwise collide across lanes. + +- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`: no lane edits + them. Name new test files conventionally so the regex seeds place them. +- `gui/src/i18n/*`: no lane in this round adds a locale key. A lane that believes it needs one stops + and reports it to the orchestrator instead of editing nine files. +- `src/config.ts`: only L3 may add a configuration field, and only the opt-in key named in #4211. + diff --git a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md new file mode 100644 index 0000000000..ffdd634bc8 --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md @@ -0,0 +1,181 @@ +# Lane dispatch packets — 260911 + +Seven implementation lanes, one Codex thread each, one worktree each, dispatched in one round. +They run concurrently because their file territories do not intersect (`010_lane_partition.md`). + +## Shared frame + +Every packet carries this frame; only `SCOPE` differs. + +**Repository.** Your worktree is listed in your packet. It is already checked out on your lane +branch, cut from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not `git worktree add`, +move, or remove any worktree, and do not touch another lane's directory. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane's scope: one work-phase per issue in your +stack, in the given order. Your goal ends when your last PR is green and reported, not when you +think the code is right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only. Use them to reproduce, to read the +call sites you are about to change, to hunt for a second caller of the same helper, and to audit +your own diff adversarially before you push. A subagent finding enters your work only with an exact +`path:line` anchor. Subagents never write, commit, push, or call a mutating `gh`. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to any shared branch, no direct push to `dev`. +- No edits to `scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json`, or + `gui/src/i18n/*`. Name new test files `tests//.test.ts` so the layout regex seeds + place them. If you believe you need one of those files, stop and report instead. +- No file outside your lane's territory. A change that needs another lane's file is a report, not a + patch. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test that `AGENTS.md` requires for a behaviour change, in the domain + directory next to the existing tests for that subsystem. You will not run it; hosted CI will. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md`. Put `Closes #` in the body. In + **Verification**, state plainly that local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry, supersede, or reimplement another author's pull request, add a `Co-authored-by` + trailer naming that author in a branch commit. Resolve the address with + `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. Prose is not a + substitute; `.github/scripts/pr-carry-attribution.cjs` reads the trailer. +- Keep a devlog unit for your lane under `devlog/_plan/260911_l_/`, with the plan, the + evidence you actually captured, and the decision you made where the issue left room. + +**Stacking.** Your first PR targets `dev`. Your second targets your first PR's head branch, your +third targets your second. `enforce-target` allows that for children of an open parent. After a +parent lands, retarget the child to `dev`. Do not register a native GitHub stack. + +**Stop conditions.** Stop and report, do not decide, when: the fix requires a policy the issue does +not already fix; the change would touch another lane's territory; a locale key or a layout-json entry +turns out to be unavoidable; or hosted CI fails for a reason outside your diff. + +**Report format to the orchestrator.** For each PR: number, exact head SHA, the CI run id and its +conclusion, the issue it closes, the co-authors credited, and anything you decided that the issue +left open. Say `NOT RUN` for local checks; never imply a suite you did not run. + +**Decision boundary.** You do not merge. You do not close another author's PR. You do not rank your +lane against another. When your last PR is green, report and stop. + +## L1 — responses and tool contract + +Worktree `~/.codex/worktrees/260911-l1/opencodex`, branch `codex/260911-l1-responses-core`. + +1. **#4172 — OpenCode Go sessionless requests omit `x-opencode-session`.** The issue fixes the + expected behaviour precisely: every request routed to the canonical Go destination carries the + header; a request with real conversation identity keeps its stable per-conversation value; a + request with no identity gets an isolated per-request value rather than none and rather than one + shared global id; an explicitly supplied header still wins. Carry PR #4184 by `chilung-cgu`, + which is at `CHANGES_REQUESTED` — read the review first and decide what survives. This is the + most urgent item in the round: upstream ended the grace period on 09/06 and now errors on + requests without the header, which is recorded with its source in the issue comments. +2. **#4176 — a routed provider prefixes a bare Codex tool with `default.`,** and + `default.view_image` is then rejected as undeclared. Two competing pull requests exist and both + are at `CHANGES_REQUESTED`: #4181 by `chilung-cgu` normalizes the invented prefix back at the + undeclared-tool guard, #4171 by `rrmlima` handles the code-mode `view_image` call through unified + exec. Land one coherent fix, credit both authors, and say in the PR body why the other shape was + not taken. + +Territory: `src/server/responses/*`, `src/server/chat-completions.ts`, `src/server/claude-messages.ts`, +`src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, +`src/providers/opencode-go-transport.ts`, `src/types/tools.ts`, +`src/responses/code-mode-helper-compat.ts`. + +`src/server/responses/core.ts` is the most contended file in the repository — four open PRs touch +it. Keep the diff minimal, do not reformat, and do not opportunistically clean up around your change. + +## L2 — catalog and provider compatibility + +Worktree `~/.codex/worktrees/260911-l2/opencodex`, branch `codex/260911-l2-catalog-provider`. + +1. **#4201 — BigModel Responses Coding Plan: missing quota probe and GLM-5.3-Flash catalog support.** + A provider-compatibility report from `bluesmilery`. #4210 by `Ingwannu` is an open draft + restoring BigModel preset quota visibility; read it before you write anything and either build on + it with attribution or stay clear of it. +2. **#4207 — the connected catalog reports success while the local Codex CLI rejects unsupported + reasoning levels.** Related to #3775, which is the same rejection seen from the CLI side. + +Territory: `src/providers/quota*.ts`, `src/codex/catalog/*`, provider preset definitions. + +`src/providers/quota.ts` is contended by four open PRs. Prefer a preset or catalog-side fix; if the +change genuinely belongs in `quota.ts`, keep it surgical and name the overlap in your report. + +## L3 — Codex account pool + +Worktree `~/.codex/worktrees/260911-l3/opencodex`, branch `codex/260911-l3-account-pool`. + +1. **#4126 — a newly created ChatGPT Free account fails Codex warmup with HTTP 404.** Carry PR + #4188 by `chilung-cgu`, which retries warmup with `gpt-5.6-luna` on 400 and 404; it was reset to + draft by the readiness gate, not rejected on merit. +2. **#4212 — an account stuck on a failed credential refresh silently drops its models.** The ask is + attribution, not new routing: when a model disappears or a request is refused because an account + is unusable, name the account and the reason on the surfaces the operator already reads. +3. **#4211 — keep Free-tier accounts out of pool selection.** Opt-in, default off, so an existing + install sees no behaviour change. This is the one place in this round where a lane may add a + configuration field, and only the key the issue names. + +Territory: `src/codex/account-*.ts`, `src/codex/plan.ts`, `src/codex/plan-from-token.ts`, +`src/codex/warmup.ts`, `src/codex/model-entitlements.ts`, `src/codex/account-pause.ts`. + +## L4 — service, update, and operator CLI + +Worktree `~/.codex/worktrees/260911-l4/opencodex`, branch `codex/260911-l4-service-cli`. + +1. **#4202 — global pnpm installations cannot self-update.** Carry PR #4203 by `oliver-mee` + (`CHANGES_REQUESTED`, 20 files). Trim it to the defect; a self-update path is not the place for + adjacent refactoring. +2. **#4169 — every stop refusal is reported as a `CODEX_HOME` ownership mismatch,** hiding + `respawnable_service` and looping the operator. Carry PR #4170 by `yeongjunyoo`. +3. **#4204 — Windows: a stale persisted CLI 0.135.0 strips max/ultra while Codex Desktop runs + 0.153.4.** #4178 by `luvs01` is review-ready with full CI green and already owns + `src/codex/cli-install-provenance.ts`. Do not duplicate it: if it lands first, rebase onto it; + otherwise keep your change out of that file and say so. + +Territory: `src/update/*`, `src/service*.ts`, `src/cli/*`, and the stop/ownership refusal paths. + +## L5 — file IO and client integrations + +Worktree `~/.codex/worktrees/260911-l5/opencodex`, branch `codex/260911-l5-integrations-io`. + +1. **#4197 — the DSH integration's atomic replace changes file ownership and causes `EACCES` across + UIDs.** The issue already argues the safe shape: do not relax the global `0600` hardening in + `atomic-write.ts`; refuse the integration write when the target exists and its owner is not the + process euid, and only then consider a metadata-preserving replace. Decide which of the two you + ship and justify it. +2. **#4214 — add Cline as a supported client integration.** Follow the existing registry pattern in + `src/integrations/registry.ts`; do not invent a parallel mechanism. + +Territory: `src/config/atomic-write.ts`, `src/integrations/*`. No open PR touches either, so this +lane is free to move fastest. + +## L6 — streaming and vendor tool leakage + +Worktree `~/.codex/worktrees/260911-l6/opencodex`, branch `codex/260911-l6-streaming-tools`. + +1. **#4191 — a long Codex thread fails only through the proxy** (WS 1006 / response prelude + timeout) while the bypass works immediately. Your first deliverable is a reproduction, not a + patch: establish what input length and timing reproduce it and where the prelude budget is + actually spent. If the cause lands inside L1's territory, stop and report rather than editing it. +2. **#4190 — vendor CLI agent scaffolding leaks into routed output** for `qoder`: an MCP + lazy-loading reminder and tool-call markup reach the user. + +## L7 — documentation + +Worktree `~/.codex/worktrees/260911-l7/opencodex`, branch `codex/260911-l7-docs`. + +1. **#4215 — state whether each provider login consumes a subscription allowance or bills per + token.** Write the rule per authentication mode, then one explicit line per provider that + supports both. +2. **#4200 — the remote hub guide breaks on a fresh config** (nested `ocx config set` fails when the + parent object is absent) and has no macOS data-plane TLS example. Fix the English source first; + translations are a follow-up, not a blocker. + +Territory: `docs-site/**` only. No `src` change belongs in this lane. + diff --git a/devlog/_plan/260911_lane_dispatch_round/060_ledger.md b/devlog/_plan/260911_lane_dispatch_round/060_ledger.md new file mode 100644 index 0000000000..d0f1bc0d65 --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/060_ledger.md @@ -0,0 +1,17 @@ +# Round ledger + +Refreshed by the orchestrator from `gh`, never from narration. `NOT RUN` is the honest value for +every local product check in this round. + +| Lane | Worktree | Branch | Thread | PR | Head SHA | Final-head CI | State | +|---|---|---|---|---|---|---|---| +| L1 | `~/.codex/worktrees/260911-l1/opencodex` | `codex/260911-l1-responses-core` | pending | — | — | — | dispatched | +| L2 | `~/.codex/worktrees/260911-l2/opencodex` | `codex/260911-l2-catalog-provider` | pending | — | — | — | dispatched | +| L3 | `~/.codex/worktrees/260911-l3/opencodex` | `codex/260911-l3-account-pool` | pending | — | — | — | dispatched | +| L4 | `~/.codex/worktrees/260911-l4/opencodex` | `codex/260911-l4-service-cli` | pending | — | — | — | dispatched | +| L5 | `~/.codex/worktrees/260911-l5/opencodex` | `codex/260911-l5-integrations-io` | pending | — | — | — | dispatched | +| L6 | `~/.codex/worktrees/260911-l6/opencodex` | `codex/260911-l6-streaming-tools` | pending | — | — | — | dispatched | +| L7 | `~/.codex/worktrees/260911-l7/opencodex` | `codex/260911-l7-docs` | pending | — | — | — | dispatched | + +Base freeze for every lane: `6d3ad12e3`. + From 1ae5c240ad487deecde5dd9a806deedd8ff79a28 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:29:34 +0900 Subject: [PATCH 015/126] docs(devlog): record the round PR and lane seed commits in the ledger --- .../260911_lane_dispatch_round/060_ledger.md | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/devlog/_plan/260911_lane_dispatch_round/060_ledger.md b/devlog/_plan/260911_lane_dispatch_round/060_ledger.md index d0f1bc0d65..8f6a08e500 100644 --- a/devlog/_plan/260911_lane_dispatch_round/060_ledger.md +++ b/devlog/_plan/260911_lane_dispatch_round/060_ledger.md @@ -3,15 +3,29 @@ Refreshed by the orchestrator from `gh`, never from narration. `NOT RUN` is the honest value for every local product check in this round. -| Lane | Worktree | Branch | Thread | PR | Head SHA | Final-head CI | State | -|---|---|---|---|---|---|---|---| -| L1 | `~/.codex/worktrees/260911-l1/opencodex` | `codex/260911-l1-responses-core` | pending | — | — | — | dispatched | -| L2 | `~/.codex/worktrees/260911-l2/opencodex` | `codex/260911-l2-catalog-provider` | pending | — | — | — | dispatched | -| L3 | `~/.codex/worktrees/260911-l3/opencodex` | `codex/260911-l3-account-pool` | pending | — | — | — | dispatched | -| L4 | `~/.codex/worktrees/260911-l4/opencodex` | `codex/260911-l4-service-cli` | pending | — | — | — | dispatched | -| L5 | `~/.codex/worktrees/260911-l5/opencodex` | `codex/260911-l5-integrations-io` | pending | — | — | — | dispatched | -| L6 | `~/.codex/worktrees/260911-l6/opencodex` | `codex/260911-l6-streaming-tools` | pending | — | — | — | dispatched | -| L7 | `~/.codex/worktrees/260911-l7/opencodex` | `codex/260911-l7-docs` | pending | — | — | — | dispatched | +## Round PR + +| PR | Head | Gate checks | Product jobs | Note | +|---|---|---|---|---| +| #4217 | `39409f9e811dc6f253a2f6bb9b92287bf2a6e73d` | `enforce-target`, `hygiene`, `label`, `resolve-pr`, `ci`, `changes`, `react-doctor`, `select windows runner` all SUCCESS | SKIPPED | Docs-only: the `changes` filter skips product legs by design | + +## Lanes + +| Lane | Worktree | Branch | Seed commit | Thread | PR | Head SHA | Final-head CI | State | +|---|---|---|---|---|---|---|---|---| +| L1 | `~/.codex/worktrees/260911-l1/opencodex` | `codex/260911-l1-responses-core` | `4d11c08d5` | pending | — | — | — | packet seeded | +| L2 | `~/.codex/worktrees/260911-l2/opencodex` | `codex/260911-l2-catalog-provider` | `4fa25c8dc` | pending | — | — | — | packet seeded | +| L3 | `~/.codex/worktrees/260911-l3/opencodex` | `codex/260911-l3-account-pool` | `8193ba524` | pending | — | — | — | packet seeded | +| L4 | `~/.codex/worktrees/260911-l4/opencodex` | `codex/260911-l4-service-cli` | `94b47e9a0` | pending | — | — | — | packet seeded | +| L5 | `~/.codex/worktrees/260911-l5/opencodex` | `codex/260911-l5-integrations-io` | `50ec0b5db` | pending | — | — | — | packet seeded | +| L6 | `~/.codex/worktrees/260911-l6/opencodex` | `codex/260911-l6-streaming-tools` | `582bdb457` | pending | — | — | — | packet seeded | +| L7 | `~/.codex/worktrees/260911-l7/opencodex` | `codex/260911-l7-docs` | `512a3e467` | pending | — | — | — | packet seeded | Base freeze for every lane: `6d3ad12e3`. +## Local checks + +`bun test`, `bun run test`, `bun run test:changed`, `bun run typecheck`, `bun run build:gui`, +`bun install`: **NOT RUN** in this round, by operator instruction. Hosted CI on each exact pushed +head is the only product evidence this round will cite. + From 1ff816a8d6d353218e098993296b3feb885012aa Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:37:12 +0900 Subject: [PATCH 016/126] docs(devlog): fold audit round 1 into the lane territories and packets --- .../260911_lane_dispatch_round/000_plan.md | 3 + .../010_lane_partition.md | 41 ++- .../020_lane_packets.md | 242 ++++++++++-------- .../030_audit_round1.md | 84 ++++++ 4 files changed, 237 insertions(+), 133 deletions(-) create mode 100644 devlog/_plan/260911_lane_dispatch_round/030_audit_round1.md diff --git a/devlog/_plan/260911_lane_dispatch_round/000_plan.md b/devlog/_plan/260911_lane_dispatch_round/000_plan.md index 715c18bca2..aecb3b0a38 100644 --- a/devlog/_plan/260911_lane_dispatch_round/000_plan.md +++ b/devlog/_plan/260911_lane_dispatch_round/000_plan.md @@ -24,6 +24,9 @@ lane has to invent a maintainer policy. An item is in when the issue states the no competing design is open; it is out when landing it would decide a policy the maintainer has not decided. +Six items inside the round also left a real choice open; audit round 1 caught that and the orchestrator + made those calls in writing (`030_audit_round1.md`), so no lane decides policy. + Excluded on purpose, with the decision that blocks each one: - #4213 — whether unknown native-surface endpoints are forwarded upstream or keep returning 404 is a diff --git a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md index d61cc1bbda..fc3d9d9e2c 100644 --- a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md +++ b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md @@ -1,8 +1,9 @@ # Lane partition — measured file collisions Method: `gh pr view --json files` over 27 open PRs on 2026-09-11, `devlog/` paths dropped, then -grouped by path. This is a snapshot; a lane that finds a new collision reports it rather than -working around it silently. +grouped by path. Territories were rewritten after audit round 1 (`030_audit_round1.md`): they are +explicit file lists, because the first version used globs and two lanes silently shared +`src/server/responses/`. ## Contended files @@ -24,30 +25,28 @@ working around it silently. Collision-free PRs, touching no file any other open PR touches: #4062, #4104, #4119, #4124, #4130, #4139, #4159, #4177, #4178, #4187, #4188, #4199. -The two `test-layout` rows are the interesting ones. #4119, #4193, and #4203 have nothing to do with -each other and still collide, because every new test file has to be registered in both. The way out -is naming, not coordination: a test file named `tests//.test.ts` is placed by the regex -seeds in `layout.json` and needs no entry at all. - ## Lanes -| Lane | Branch | File territory | Stack order | +| Lane | Branch | Owned files | Stack order | |---|---|---|---| -| L1 | `codex/260911-l1-responses-core` | `src/server/responses/*`, `src/server/chat-completions.ts`, `src/server/claude-messages.ts`, `src/providers/opencode-go-transport.ts`, `src/types/tools.ts`, `src/responses/code-mode-helper-compat.ts` | #4172 → #4176 | -| L2 | `codex/260911-l2-catalog-provider` | `src/providers/quota*.ts`, `src/codex/catalog/*`, BigModel provider preset | #4201 → #4207 | -| L3 | `codex/260911-l3-account-pool` | `src/codex/account-*.ts`, `plan*.ts`, `warmup.ts`, `model-entitlements.ts` | #4126 → #4212 → #4211 | -| L4 | `codex/260911-l4-service-cli` | `src/update/*`, `src/service*.ts`, `src/cli/*`, stop/ownership refusal paths | #4202 → #4169 → #4204 | +| L1 | `codex/260911-l1-responses-core` | `src/server/responses/core.ts`, `compact.ts`, `policy-fallback.ts`, `src/server/chat-completions.ts`, `src/server/claude-messages.ts`, `src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, `src/providers/opencode-go-transport.ts`, `src/types/tools.ts` | #4172 → #4176 | +| L2 | `codex/260911-l2-catalog-provider` | `src/providers/quota*.ts`, BigModel preset definitions, `src/codex/catalog/*` except `effort.ts` | #4201 | +| L3 | `codex/260911-l3-account-pool` | `src/codex/account-*.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, `model-entitlements.ts`, `src/server/responses/codex-auth-error.ts`, the one key in `src/config.ts` | #4126 → #4212 → #4211 | +| L4 | `codex/260911-l4-service-cli` | `src/update/*`, `src/service*.ts`, `src/cli/*`, `src/client/*`, `src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, `src/codex/cli-install-provenance.ts` | #4202 → #4169 → #4204 → #4207 | | L5 | `codex/260911-l5-integrations-io` | `src/config/atomic-write.ts`, `src/integrations/*` | #4197 → #4214 | -| L6 | `codex/260911-l6-streaming-tools` | streaming/WebSocket prelude paths, vendor scaffolding filters | #4191 → #4190 | -| L7 | `codex/260911-l7-docs` | `docs-site/**` only | #4215 → #4200 | +| L6 | `codex/260911-l6-streaming-tools` | `src/server/responses/codex-ws-exchange.ts`, `codex-ws-wire.ts`, `src/adapters/qoder/*` | #4191 → #4190 | +| L7 | `codex/260911-l7-docs` | `docs-site/**/guides/providers.md`, `docs-site/**/guides/remote-hub.md` | #4215 → #4200 | ## Custody of shared assets -Three assets are owned by nobody in this round and would otherwise collide across lanes. - -- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`: no lane edits - them. Name new test files conventionally so the regex seeds place them. -- `gui/src/i18n/*`: no lane in this round adds a locale key. A lane that believes it needs one stops - and reports it to the orchestrator instead of editing nine files. -- `src/config.ts`: only L3 may add a configuration field, and only the opt-in key named in #4211. +- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`: every lane + registers its own new test files in both maps, as `AGENTS.md` requires. These are append-only + lists, so the conflicts are mechanical; the orchestrator resolves them during the serialized + merges. Audit round 1 rejected the earlier rule that told lanes to avoid the maps by naming + convention, because the regex seeds are a placement fallback and not a substitute for the entry. +- `gui/src/i18n/*`: no lane in this round adds a locale key. A lane that needs one stops and reports + instead of editing nine files. +- `src/config.ts`: only L3 may add a field, and only `codexPool.excludedPlans`. +- Documentation: L7 owns the two guide pages it is fixing. Any other lane may update the page that + documents its own change, including the pages a carried PR already touches. diff --git a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md index ffdd634bc8..3232bed5ff 100644 --- a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md +++ b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md @@ -1,35 +1,32 @@ -# Lane dispatch packets — 260911 +# Lane dispatch packets — 260911 (revision 2, after audit round 1) Seven implementation lanes, one Codex thread each, one worktree each, dispatched in one round. -They run concurrently because their file territories do not intersect (`010_lane_partition.md`). +Territories are explicit file lists so that no two lanes own the same file; revision 1 used globs +and audit round 1 found two silent overlaps (`030_audit_round1.md`). ## Shared frame -Every packet carries this frame; only `SCOPE` differs. +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree, and +do not open another lane's directory. -**Repository.** Your worktree is listed in your packet. It is already checked out on your lane -branch, cut from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not `git worktree add`, -move, or remove any worktree, and do not touch another lane's directory. +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in the given +order. Your goal ends when your last PR is green and reported, not when the code looks right. -**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane's scope: one work-phase per issue in your -stack, in the given order. Your goal ends when your last PR is green and reported, not when you -think the code is right. - -**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only. Use them to reproduce, to read the -call sites you are about to change, to hunt for a second caller of the same helper, and to audit -your own diff adversarially before you push. A subagent finding enters your work only with an exact -`path:line` anchor. Subagents never write, commit, push, or call a mutating `gh`. +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, +to find a second caller of a helper you are touching, and to review your own staged diff +adversarially before you push. A finding enters your work only with an exact `path:line` anchor. +Subagents never write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this +round did: fold it in and re-audit, do not argue with it. **MUST NOT.** - No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. -- No merge, no release, no force-push to any shared branch, no direct push to `dev`. -- No edits to `scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json`, or - `gui/src/i18n/*`. Name new test files `tests//.test.ts` so the layout regex seeds - place them. If you believe you need one of those files, stop and report instead. -- No file outside your lane's territory. A change that needs another lane's file is a report, not a - patch. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No file outside your owned list. A fix that needs another lane's file is a report, not a patch. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. - No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. **MUST.** @@ -37,72 +34,79 @@ your own diff adversarially before you push. A subagent finding enters your work - Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. - Push with `--no-verify`. -- Write the focused regression test that `AGENTS.md` requires for a behaviour change, in the domain - directory next to the existing tests for that subsystem. You will not run it; hosted CI will. -- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md`. Put `Closes #` in the body. In - **Verification**, state plainly that local suite, typecheck, and build were `NOT RUN` by operator - instruction and that hosted CI on the exact pushed head is the proof. -- When you carry, supersede, or reimplement another author's pull request, add a `Co-authored-by` - trailer naming that author in a branch commit. Resolve the address with - `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. Prose is not a - substitute; `.github/scripts/pr-carry-attribution.cjs` reads the trailer. -- Keep a devlog unit for your lane under `devlog/_plan/260911_l_/`, with the plan, the - evidence you actually captured, and the decision you made where the issue left room. - -**Stacking.** Your first PR targets `dev`. Your second targets your first PR's head branch, your -third targets your second. `enforce-target` allows that for children of an open parent. After a -parent lands, retarget the child to `dev`. Do not register a native GitHub stack. - -**Stop conditions.** Stop and report, do not decide, when: the fix requires a policy the issue does -not already fix; the change would touch another lane's territory; a locale key or a layout-json entry -turns out to be unavoidable; or hosted CI fails for a reason outside your diff. - -**Report format to the orchestrator.** For each PR: number, exact head SHA, the CI run id and its -conclusion, the issue it closes, the co-authors credited, and anything you decided that the issue -left open. Say `NOT RUN` for local checks; never imply a suite you did not run. - -**Decision boundary.** You do not merge. You do not close another author's PR. You do not rank your -lane against another. When your last PR is green, report and stop. - -## L1 — responses and tool contract +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only lists and other lanes are adding to + them too — the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state plainly that the local suite, typecheck, and build were `NOT RUN` by + operator instruction and that hosted CI on the exact pushed head is the proof. +- When you carry, supersede, or reimplement another author's PR, add a `Co-authored-by` trailer in a + branch commit. Resolve the address with `gh api users/ --jq '.id'` and use + `+@users.noreply.github.com`. Prose is not a substitute; + `.github/scripts/pr-carry-attribution.cjs` reads the trailer. +- Keep a devlog unit under `devlog/_plan/260911_l_/` with your plan, the evidence you + actually captured, and any decision the issue left open. + +**Stacking.** Your first PR targets `dev`; your second targets your first PR's head branch, your +third targets your second. `enforce-target` allows that for children of an open parent. Retarget a +child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Audit round 1 found six items where the issue left a real choice +open, which would have made the lane decide maintainer policy. Those calls are recorded in your +packet. Implement the recorded decision; if you believe it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a file you do not own, when it needs a policy +no issue has fixed, when a locale key turns out to be unavoidable, or when hosted CI fails for a +reason outside your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, and any decision you made. Say `NOT RUN` for local checks; never imply a suite +you did not run. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L1 — Responses pipeline and tool contract Worktree `~/.codex/worktrees/260911-l1/opencodex`, branch `codex/260911-l1-responses-core`. +Owned files: `src/server/responses/core.ts`, `compact.ts`, `policy-fallback.ts`, +`src/server/chat-completions.ts`, `src/server/claude-messages.ts`, +`src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, +`src/providers/opencode-go-transport.ts`, `src/types/tools.ts`. You do **not** own +`codex-ws-exchange.ts`, `codex-ws-wire.ts` (L6) or `codex-auth-error.ts` (L3). + 1. **#4172 — OpenCode Go sessionless requests omit `x-opencode-session`.** The issue fixes the - expected behaviour precisely: every request routed to the canonical Go destination carries the - header; a request with real conversation identity keeps its stable per-conversation value; a - request with no identity gets an isolated per-request value rather than none and rather than one - shared global id; an explicitly supplied header still wins. Carry PR #4184 by `chilung-cgu`, - which is at `CHANGES_REQUESTED` — read the review first and decide what survives. This is the - most urgent item in the round: upstream ended the grace period on 09/06 and now errors on - requests without the header, which is recorded with its source in the issue comments. + expected behaviour: every request routed to the canonical Go destination carries the header; a + request with real conversation identity keeps its stable per-conversation value; a request with + no identity gets an isolated per-request value rather than none and rather than one shared global + id; an explicitly supplied header still wins. Carry PR #4184 by `chilung-cgu` (open, not a draft, + `CHANGES_REQUESTED`) — read the review before rewriting. Most urgent item in the round: upstream + ended the grace period on 09/06 and now errors on requests without the header. 2. **#4176 — a routed provider prefixes a bare Codex tool with `default.`,** and - `default.view_image` is then rejected as undeclared. Two competing pull requests exist and both - are at `CHANGES_REQUESTED`: #4181 by `chilung-cgu` normalizes the invented prefix back at the - undeclared-tool guard, #4171 by `rrmlima` handles the code-mode `view_image` call through unified - exec. Land one coherent fix, credit both authors, and say in the PR body why the other shape was - not taken. - -Territory: `src/server/responses/*`, `src/server/chat-completions.ts`, `src/server/claude-messages.ts`, -`src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, -`src/providers/opencode-go-transport.ts`, `src/types/tools.ts`, -`src/responses/code-mode-helper-compat.ts`. + `default.view_image` is rejected as undeclared. **Decision: normalize the invented prefix back at + the undeclared-tool guard**, the #4181 shape, because that is the expected behaviour the issue + states. #4181 by `chilung-cgu` is open, not a draft, `CHANGES_REQUESTED`; #4171 by `rrmlima` is a + **draft** at `CHANGES_REQUESTED` and its unified-exec rewrite is out of round scope. Credit + `rrmlima` only if you reuse code from #4171. -`src/server/responses/core.ts` is the most contended file in the repository — four open PRs touch -it. Keep the diff minimal, do not reformat, and do not opportunistically clean up around your change. +`core.ts` is the most contended file in the repository — four open PRs touch it. Keep the diff +minimal and do not reformat around your change. -## L2 — catalog and provider compatibility +## L2 — provider quota and catalog Worktree `~/.codex/worktrees/260911-l2/opencodex`, branch `codex/260911-l2-catalog-provider`. -1. **#4201 — BigModel Responses Coding Plan: missing quota probe and GLM-5.3-Flash catalog support.** - A provider-compatibility report from `bluesmilery`. #4210 by `Ingwannu` is an open draft - restoring BigModel preset quota visibility; read it before you write anything and either build on - it with attribution or stay clear of it. -2. **#4207 — the connected catalog reports success while the local Codex CLI rejects unsupported - reasoning levels.** Related to #3775, which is the same rejection seen from the CLI side. +Owned files: `src/providers/quota*.ts`, BigModel preset definitions, `src/codex/catalog/*` except +`effort.ts` (L4). -Territory: `src/providers/quota*.ts`, `src/codex/catalog/*`, provider preset definitions. +1. **#4201 — BigModel Responses Coding Plan: missing quota probe and GLM-5.3-Flash catalog support.** + A provider-compatibility report from `bluesmilery`. #4210 by `Ingwannu` is an **open draft** at + `REVIEW_REQUIRED` restoring BigModel preset quota visibility, and it also touches + `docs-site/**/guides/providers.md`. Read it first and either build on it with attribution or stay + clear of it. `src/providers/quota.ts` is contended by four open PRs. Prefer a preset or catalog-side fix; if the change genuinely belongs in `quota.ts`, keep it surgical and name the overlap in your report. @@ -111,71 +115,85 @@ change genuinely belongs in `quota.ts`, keep it surgical and name the overlap in Worktree `~/.codex/worktrees/260911-l3/opencodex`, branch `codex/260911-l3-account-pool`. -1. **#4126 — a newly created ChatGPT Free account fails Codex warmup with HTTP 404.** Carry PR - #4188 by `chilung-cgu`, which retries warmup with `gpt-5.6-luna` on 400 and 404; it was reset to - draft by the readiness gate, not rejected on merit. +Owned files: `src/codex/account-*.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, +`model-entitlements.ts`, `src/server/responses/codex-auth-error.ts`, and one key in `src/config.ts`. + +1. **#4126 — a newly created ChatGPT Free account fails Codex warmup with HTTP 404.** Carry PR #4188 + by `chilung-cgu` (open **draft**, `REVIEW_REQUIRED`, reset by the readiness gate rather than + rejected on merit). It also carries eight `docs-site/**/guides/codex-integration.md` files; keep + the documentation that describes this change. 2. **#4212 — an account stuck on a failed credential refresh silently drops its models.** The ask is attribution, not new routing: when a model disappears or a request is refused because an account - is unusable, name the account and the reason on the surfaces the operator already reads. -3. **#4211 — keep Free-tier accounts out of pool selection.** Opt-in, default off, so an existing - install sees no behaviour change. This is the one place in this round where a lane may add a - configuration field, and only the key the issue names. + is unusable, name the account and the reason where the operator already looks. The refusal string + lives at `src/server/responses/codex-auth-error.ts:35`, which is yours for this round. +3. **#4211 — keep Free-tier accounts out of pool selection.** **Decision: ship** + **`codexPool.excludedPlans` as an array, absent by default**, so an existing install sees no + behaviour change. Do not ship `minimumPlan`: ranking plans needs an ordering this repository does + not have. -Territory: `src/codex/account-*.ts`, `src/codex/plan.ts`, `src/codex/plan-from-token.ts`, -`src/codex/warmup.ts`, `src/codex/model-entitlements.ts`, `src/codex/account-pause.ts`. - -## L4 — service, update, and operator CLI +## L4 — service, update, CLI, and connected client Worktree `~/.codex/worktrees/260911-l4/opencodex`, branch `codex/260911-l4-service-cli`. -1. **#4202 — global pnpm installations cannot self-update.** Carry PR #4203 by `oliver-mee` - (`CHANGES_REQUESTED`, 20 files). Trim it to the defect; a self-update path is not the place for - adjacent refactoring. -2. **#4169 — every stop refusal is reported as a `CODEX_HOME` ownership mismatch,** hiding - `respawnable_service` and looping the operator. Carry PR #4170 by `yeongjunyoo`. -3. **#4204 — Windows: a stale persisted CLI 0.135.0 strips max/ultra while Codex Desktop runs - 0.153.4.** #4178 by `luvs01` is review-ready with full CI green and already owns - `src/codex/cli-install-provenance.ts`. Do not duplicate it: if it lands first, rebase onto it; - otherwise keep your change out of that file and say so. +Owned files: `src/update/*`, `src/service*.ts`, `src/cli/*`, `src/client/*`, +`src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, `src/codex/cli-install-provenance.ts`. -Territory: `src/update/*`, `src/service*.ts`, `src/cli/*`, and the stop/ownership refusal paths. +1. **#4202 — global pnpm installations cannot self-update.** Carry PR #4203 by `oliver-mee` (open + **draft**, `CHANGES_REQUESTED`, **36 files**). **Decision: trim to the pnpm self-update path plus + the tests and the one documentation page that path requires.** +2. **#4169 — every stop refusal is reported as a `CODEX_HOME` ownership mismatch,** hiding + `respawnable_service` and looping the operator. Carry PR #4170 by `yeongjunyoo` (open **draft**, + `REVIEW_REQUIRED`); it touches `src/lib/process-control.ts`, which you own. +3. **#4204 — Windows: a stale persisted CLI 0.135.0 strips max/ultra while Desktop runs 0.153.4.** + The clamp is `src/codex/catalog/effort.ts:441`. #4178 by `luvs01` is open, not a draft, with full + CI green and already owns `src/codex/cli-install-provenance.ts`: if it lands first, rebase onto + it; otherwise keep your change out of that file and say so. +4. **#4207 — the connected catalog reports success while the local Codex CLI rejects unsupported + reasoning levels.** Same clamp as #4204, which is why both live here. Client side is + `src/client/hub-client.ts:145`, `src/client/connect.ts:542`, `src/cli/connect.ts:187`. + **Decision: fail closed — when the projection is not compatible with the local client, block + readiness rather than reporting success.** ## L5 — file IO and client integrations Worktree `~/.codex/worktrees/260911-l5/opencodex`, branch `codex/260911-l5-integrations-io`. +Owned files: `src/config/atomic-write.ts`, `src/integrations/*`. No open PR touches either, so this +lane can move fastest. + 1. **#4197 — the DSH integration's atomic replace changes file ownership and causes `EACCES` across - UIDs.** The issue already argues the safe shape: do not relax the global `0600` hardening in - `atomic-write.ts`; refuse the integration write when the target exists and its owner is not the - process euid, and only then consider a metadata-preserving replace. Decide which of the two you - ship and justify it. + UIDs.** **Decision: refuse the integration write when the target exists and its owner is not the + process euid, with an explicit API error. Do not relax the `0600` hardening and do not attempt + `fchown`.** A metadata-preserving replace can be proposed afterwards as its own issue. 2. **#4214 — add Cline as a supported client integration.** Follow the existing registry pattern in `src/integrations/registry.ts`; do not invent a parallel mechanism. -Territory: `src/config/atomic-write.ts`, `src/integrations/*`. No open PR touches either, so this -lane is free to move fastest. - ## L6 — streaming and vendor tool leakage Worktree `~/.codex/worktrees/260911-l6/opencodex`, branch `codex/260911-l6-streaming-tools`. -1. **#4191 — a long Codex thread fails only through the proxy** (WS 1006 / response prelude - timeout) while the bypass works immediately. Your first deliverable is a reproduction, not a - patch: establish what input length and timing reproduce it and where the prelude budget is - actually spent. If the cause lands inside L1's territory, stop and report rather than editing it. +Owned files: `src/server/responses/codex-ws-exchange.ts`, `codex-ws-wire.ts`, `src/adapters/qoder/*`. + +1. **#4191 — a long Codex thread fails only through the proxy** (WS 1006 / response prelude timeout) + while the bypass works immediately. The prelude timeout is + `src/server/responses/codex-ws-exchange.ts:214`. Reproduce first: establish what length and timing + trigger it and where the prelude budget is spent. **Decision: the only in-scope fix is to classify + and report the timeout honestly, including the close code and the cause.** A configurable prelude + budget, an SSE fallback, or a size preflight comes back as a report, not a patch. 2. **#4190 — vendor CLI agent scaffolding leaks into routed output** for `qoder`: an MCP - lazy-loading reminder and tool-call markup reach the user. + lazy-loading reminder and tool-call markup reach the user. The leak is in `src/adapters/qoder/`. ## L7 — documentation Worktree `~/.codex/worktrees/260911-l7/opencodex`, branch `codex/260911-l7-docs`. +Owned files: `docs-site/**/guides/providers.md` and `docs-site/**/guides/remote-hub.md`. Other lanes +own the pages that document their own changes. + 1. **#4215 — state whether each provider login consumes a subscription allowance or bills per - token.** Write the rule per authentication mode, then one explicit line per provider that - supports both. + token.** Write the rule per authentication mode, then one explicit line per provider that supports + both. Coordinate through the orchestrator if L2's carry of #4210 also edits `providers.md`. 2. **#4200 — the remote hub guide breaks on a fresh config** (nested `ocx config set` fails when the parent object is absent) and has no macOS data-plane TLS example. Fix the English source first; translations are a follow-up, not a blocker. -Territory: `docs-site/**` only. No `src` change belongs in this lane. - diff --git a/devlog/_plan/260911_lane_dispatch_round/030_audit_round1.md b/devlog/_plan/260911_lane_dispatch_round/030_audit_round1.md new file mode 100644 index 0000000000..c1eff86742 --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/030_audit_round1.md @@ -0,0 +1,84 @@ +# Audit round 1 — reviewer verdict and dispositions + +Reviewer: `xai/grok-4.6` explorer subagent, read-only, dispatched against the orch worktree. +Verdict: **fail**. Seven anchored findings, all accepted. The dispositions below are folded into +`010_lane_partition.md` and `020_lane_packets.md` before any lane is dispatched. + +## F1 — L1 and L6 both need the Responses WebSocket path + +Anchor: L1 was given the glob `src/server/responses/*` while L6 was given "streaming/WebSocket +prelude paths"; the prelude timeout L6 must investigate is +`src/server/responses/codex-ws-exchange.ts:214` (`failStream("codex websocket response prelude +timed out")`). The packet even told L6 to stop if the cause landed in L1's territory, which is an +admission that the boundary was wrong. + +Disposition: territories are now explicit file lists, not globs. `codex-ws-exchange.ts` and +`codex-ws-wire.ts` belong to L6; L1 keeps `core.ts`, `chat-completions.ts`, `claude-messages.ts`, +`compact.ts`, `policy-fallback.ts`, and the undeclared-tool guard. + +## F2 — L3's #4212 needs a Responses file + +Anchor: the issue names `src/server/responses/codex-auth-error.ts:35`, which sat inside L1's glob +while L3 owned only `src/codex/*`. + +Disposition: `src/server/responses/codex-auth-error.ts` is assigned to L3. No open PR touches it, so +the assignment costs L1 nothing. + +## F3 — #4207 is client work and collides with #4204 + +Anchor: #4207 names `src/client/hub-client.ts:145`, `src/client/connect.ts:542`, +`src/codex/catalog/effort.ts:441`, `src/cli/connect.ts:187`. #4204 is the same max/ultra clamp on +`effort.ts:441`. They were split across L2 and L4, and `src/client/*` was owned by nobody. + +Disposition: #4207 moves to L4, which now owns `src/client/*` and `src/codex/catalog/effort.ts`. +Two issues that clamp the same line are now one serialized stack. L2 keeps only #4201. + +## F4 — carried PRs drag files out of their lane + +Anchor: #4188 carries eight `docs-site/**` files, #4210 one, #4203 thirty-six files including +`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`, #4170 touches +`src/lib/process-control.ts`, and #4190's leak lives in `src/adapters/qoder/adapter.ts`, which no +lane owned. + +Disposition: L7 owns only the two documentation pages it is fixing +(`docs-site/**/guides/providers.md`, `docs-site/**/guides/remote-hub.md`); any lane may update the +documentation page that describes its own change. `src/lib/process-control.ts` is added to L4, +`src/adapters/qoder/*` to L6. The #4203 file count is corrected to 36. + +## F5 — two PR states were stated wrong + +Anchor: #4171 is a draft at `CHANGES_REQUESTED`, and #4170 is a draft at `REVIEW_REQUIRED`; the +packet described neither as a draft. + +Disposition: corrected in place. + +## F6 — the test-layout rule contradicted AGENTS.md + +Anchor: the packet said to avoid `layout.json` by naming files conventionally; `AGENTS.md:23` says a +new test file needs an entry in both `layout.json` `explicit` and +`tests/fixtures/test-layout-expected.json`, with the regex seeds as a temporary placement only. + +Disposition: the avoidance rule is withdrawn. Every lane registers its own test files in both maps +as AGENTS.md requires, and the orchestrator resolves the resulting append-only conflicts during the +serialized merges. A rule that tells a lane to skip a repository requirement is worse than a +conflict that takes a minute to resolve. + +## F7 — six items were not actually decision-free + +Anchor: #4197 (refuse versus preserve metadata), #4211 (`excludedPlans` versus `minimumPlan`), +#4176 (prefix normalization versus the unified-exec rewrite), #4191 (four competing mitigations), +#4207 (compatible projection versus blocking readiness), and #4203 (what "trim" means) each left a +choice open, which contradicts the round's own decision-free filter. + +Disposition: the orchestrator makes those six calls now, in writing, and the lanes implement them. +They are recorded in `020_lane_packets.md` per lane: + +| Item | Decision | +|---|---| +| #4197 | Refuse the integration write when the target exists and its owner is not the process euid, with an explicit API error. Do not relax the `0600` hardening and do not attempt `fchown`. | +| #4211 | Ship `codexPool.excludedPlans` as an array, absent by default. Do not ship `minimumPlan`: ordering plans requires a rank this repository does not have. | +| #4176 | Normalize the invented `default.` prefix back at the undeclared-tool guard, which is what the issue states as expected behaviour. The unified-exec rewrite is out of round scope. | +| #4191 | Reproduce first. The only in-scope fix is to classify and report the prelude timeout honestly, including the close code and the cause. A configurable prelude budget is a report, not a patch. | +| #4207 | Fail closed: when the projection is not compatible with the local client, block readiness rather than reporting success. | +| #4203 | Trim to the pnpm self-update path plus the tests and the one documentation page that path requires. | + From 555321ee5e6da84a73f8ad8eef21fb5e2f7989c6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:49:12 +0900 Subject: [PATCH 017/126] docs(devlog): fold audit round 2 into explicit lane ownership and named keep-sets --- .../010_lane_partition.md | 66 ++++-- .../020_lane_packets.md | 222 +++++++++--------- .../040_audit_round2.md | 32 +++ 3 files changed, 191 insertions(+), 129 deletions(-) create mode 100644 devlog/_plan/260911_lane_dispatch_round/040_audit_round2.md diff --git a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md index fc3d9d9e2c..ef23a3f531 100644 --- a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md +++ b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md @@ -1,9 +1,10 @@ # Lane partition — measured file collisions Method: `gh pr view --json files` over 27 open PRs on 2026-09-11, `devlog/` paths dropped, then -grouped by path. Territories were rewritten after audit round 1 (`030_audit_round1.md`): they are -explicit file lists, because the first version used globs and two lanes silently shared -`src/server/responses/`. +grouped by path. Revision 3, after two audit rounds (`030_audit_round1.md`, `040_audit_round2.md`). + +Ownership below is stated as an exact path or as one named directory meaning every file under it. +No two lanes name the same path, and no named directory contains another lane's path. ## Contended files @@ -25,28 +26,49 @@ explicit file lists, because the first version used globs and two lanes silently Collision-free PRs, touching no file any other open PR touches: #4062, #4104, #4119, #4124, #4130, #4139, #4159, #4177, #4178, #4187, #4188, #4199. -## Lanes +## Ownership + +**L1** `codex/260911-l1-responses-core` — `src/server/responses/core.ts`, +`src/server/responses/compact.ts`, `src/server/responses/policy-fallback.ts`, +`src/server/chat-completions.ts`, `src/server/claude-messages.ts`, +`src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, +`src/providers/opencode-go-transport.ts`, `src/types/tools.ts`. Stack: #4172 → #4176. + +**L2** `codex/260911-l2-catalog-provider` — `src/providers/quota.ts`, +`src/providers/quota-types.ts`, `src/providers/quota-wire.ts`, +`src/providers/quota-routing-cache.ts`, `src/providers/quota-key-accounts.ts`, +`src/providers/account-quota-disk.ts`, `src/providers/registry.ts`. Stack: #4201. + +**L3** `codex/260911-l3-account-pool` — `src/codex/account-usability.ts`, +`src/codex/account-pause.ts`, `src/codex/account-store.ts`, `src/codex/account-runtime-state.ts`, +`src/codex/plan.ts`, `src/codex/plan-from-token.ts`, `src/codex/warmup.ts`, +`src/codex/model-entitlements.ts`, `src/server/responses/codex-auth-error.ts`, +`src/server/management/oauth-account-routes.ts`, the single key `codexPool.excludedPlans` in +`src/config.ts`, and `docs-site/src/content/docs/**/guides/codex-integration.md`. +Stack: #4126 → #4212 → #4211. + +**L4** `codex/260911-l4-service-cli` — directories `src/update/`, `src/cli/`, `src/client/`; files +`bin/ocx.mjs`, `src/cli.ts`, `src/service.ts`, `src/config/pending-teardown.ts`, +`src/lib/bun-runtime.ts`, `src/lib/package-tree-integrity.ts`, `src/lib/process-control.ts`, +`src/codex/catalog/effort.ts`, `src/codex/cli-install-provenance.ts`, +`docs-site/src/content/docs/getting-started/installation.md`. Stack: #4202 → #4169 → #4204 → #4207. + +**L5** `codex/260911-l5-integrations-io` — directory `src/integrations/`; file +`src/config/atomic-write.ts`. Stack: #4197 → #4214. + +**L6** `codex/260911-l6-streaming-tools` — `src/server/responses/codex-ws-exchange.ts`, +`src/server/responses/codex-ws-wire.ts`, directory `src/adapters/qoder/`. Stack: #4191 → #4190. -| Lane | Branch | Owned files | Stack order | -|---|---|---|---| -| L1 | `codex/260911-l1-responses-core` | `src/server/responses/core.ts`, `compact.ts`, `policy-fallback.ts`, `src/server/chat-completions.ts`, `src/server/claude-messages.ts`, `src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, `src/providers/opencode-go-transport.ts`, `src/types/tools.ts` | #4172 → #4176 | -| L2 | `codex/260911-l2-catalog-provider` | `src/providers/quota*.ts`, BigModel preset definitions, `src/codex/catalog/*` except `effort.ts` | #4201 | -| L3 | `codex/260911-l3-account-pool` | `src/codex/account-*.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, `model-entitlements.ts`, `src/server/responses/codex-auth-error.ts`, the one key in `src/config.ts` | #4126 → #4212 → #4211 | -| L4 | `codex/260911-l4-service-cli` | `src/update/*`, `src/service*.ts`, `src/cli/*`, `src/client/*`, `src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, `src/codex/cli-install-provenance.ts` | #4202 → #4169 → #4204 → #4207 | -| L5 | `codex/260911-l5-integrations-io` | `src/config/atomic-write.ts`, `src/integrations/*` | #4197 → #4214 | -| L6 | `codex/260911-l6-streaming-tools` | `src/server/responses/codex-ws-exchange.ts`, `codex-ws-wire.ts`, `src/adapters/qoder/*` | #4191 → #4190 | -| L7 | `codex/260911-l7-docs` | `docs-site/**/guides/providers.md`, `docs-site/**/guides/remote-hub.md` | #4215 → #4200 | +**L7** `codex/260911-l7-docs` — `docs-site/src/content/docs/guides/providers.md`, +`docs-site/src/content/docs/guides/remote-hub.md`. Stack: #4215 → #4200. ## Custody of shared assets - `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`: every lane - registers its own new test files in both maps, as `AGENTS.md` requires. These are append-only - lists, so the conflicts are mechanical; the orchestrator resolves them during the serialized - merges. Audit round 1 rejected the earlier rule that told lanes to avoid the maps by naming - convention, because the regex seeds are a placement fallback and not a substitute for the entry. -- `gui/src/i18n/*`: no lane in this round adds a locale key. A lane that needs one stops and reports - instead of editing nine files. -- `src/config.ts`: only L3 may add a field, and only `codexPool.excludedPlans`. -- Documentation: L7 owns the two guide pages it is fixing. Any other lane may update the page that - documents its own change, including the pages a carried PR already touches. + registers its own new test files in both, as `AGENTS.md` requires. They are append-only lists, so + the conflicts are mechanical and the orchestrator resolves them during the serialized merges. +- `gui/src/i18n/*`: no lane adds a locale key this round. A lane that needs one stops and reports. +- `src/config.ts`: only L3, and only `codexPool.excludedPlans`. +- `docs-site/src/content/docs/guides/providers.md`: L7 only. A lane whose carried PR edits it drops + that hunk and reports the wording to the orchestrator. diff --git a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md index 3232bed5ff..654f643546 100644 --- a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md +++ b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md @@ -1,31 +1,31 @@ -# Lane dispatch packets — 260911 (revision 2, after audit round 1) +# Lane dispatch packets — 260911 (revision 3, after audit rounds 1 and 2) -Seven implementation lanes, one Codex thread each, one worktree each, dispatched in one round. -Territories are explicit file lists so that no two lanes own the same file; revision 1 used globs -and audit round 1 found two silent overlaps (`030_audit_round1.md`). +Seven implementation lanes, one Codex thread each, one worktree each. Ownership is an exact path or +one named directory; `010_lane_partition.md` is the authoritative list and this file repeats each +lane's slice of it. ## Shared frame **Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut -from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree, and -do not open another lane's directory. +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. -**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in the given -order. Your goal ends when your last PR is green and reported, not when the code looks right. +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. **Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` -(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, -to find a second caller of a helper you are touching, and to review your own staged diff -adversarially before you push. A finding enters your work only with an exact `path:line` anchor. -Subagents never write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this -round did: fold it in and re-audit, do not argue with it. +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. **MUST NOT.** - No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. - No merge, no release, no force-push to a shared branch, no direct push to `dev`. -- No file outside your owned list. A fix that needs another lane's file is a report, not a patch. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. - No locale key in `gui/src/i18n/*`. If you need one, stop and report. - No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. @@ -37,33 +37,29 @@ round did: fold it in and re-audit, do not argue with it. - Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain directory beside the existing tests for that subsystem, and register it in both `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You - will not run it; hosted CI will. Those two maps are append-only lists and other lanes are adding to - them too — the orchestrator resolves the conflicts at merge, so do not skip the entry. + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. - Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In - **Verification**, state plainly that the local suite, typecheck, and build were `NOT RUN` by - operator instruction and that hosted CI on the exact pushed head is the proof. -- When you carry, supersede, or reimplement another author's PR, add a `Co-authored-by` trailer in a - branch commit. Resolve the address with `gh api users/ --jq '.id'` and use - `+@users.noreply.github.com`. Prose is not a substitute; - `.github/scripts/pr-carry-attribution.cjs` reads the trailer. -- Keep a devlog unit under `devlog/_plan/260911_l_/` with your plan, the evidence you - actually captured, and any decision the issue left open. - -**Stacking.** Your first PR targets `dev`; your second targets your first PR's head branch, your -third targets your second. `enforce-target` allows that for children of an open parent. Retarget a -child to `dev` after its parent lands. No native GitHub stacks. - -**Decisions already made for you.** Audit round 1 found six items where the issue left a real choice -open, which would have made the lane decide maintainer policy. Those calls are recorded in your -packet. Implement the recorded decision; if you believe it is wrong, report the reason and stop. - -**Stop conditions.** Stop and report when the fix needs a file you do not own, when it needs a policy -no issue has fixed, when a locale key turns out to be unavoidable, or when hosted CI fails for a -reason outside your diff. + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. **Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the -co-authors credited, and any decision you made. Say `NOT RUN` for local checks; never imply a suite -you did not run. +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. **Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane against another. When your last PR is green, report and stop. @@ -72,62 +68,64 @@ against another. When your last PR is green, report and stop. Worktree `~/.codex/worktrees/260911-l1/opencodex`, branch `codex/260911-l1-responses-core`. -Owned files: `src/server/responses/core.ts`, `compact.ts`, `policy-fallback.ts`, -`src/server/chat-completions.ts`, `src/server/claude-messages.ts`, -`src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, -`src/providers/opencode-go-transport.ts`, `src/types/tools.ts`. You do **not** own -`codex-ws-exchange.ts`, `codex-ws-wire.ts` (L6) or `codex-auth-error.ts` (L3). - -1. **#4172 — OpenCode Go sessionless requests omit `x-opencode-session`.** The issue fixes the - expected behaviour: every request routed to the canonical Go destination carries the header; a - request with real conversation identity keeps its stable per-conversation value; a request with - no identity gets an isolated per-request value rather than none and rather than one shared global - id; an explicitly supplied header still wins. Carry PR #4184 by `chilung-cgu` (open, not a draft, - `CHANGES_REQUESTED`) — read the review before rewriting. Most urgent item in the round: upstream - ended the grace period on 09/06 and now errors on requests without the header. -2. **#4176 — a routed provider prefixes a bare Codex tool with `default.`,** and - `default.view_image` is rejected as undeclared. **Decision: normalize the invented prefix back at - the undeclared-tool guard**, the #4181 shape, because that is the expected behaviour the issue - states. #4181 by `chilung-cgu` is open, not a draft, `CHANGES_REQUESTED`; #4171 by `rrmlima` is a - **draft** at `CHANGES_REQUESTED` and its unified-exec rewrite is out of round scope. Credit +Owned: `src/server/responses/core.ts`, `src/server/responses/compact.ts`, +`src/server/responses/policy-fallback.ts`, `src/server/chat-completions.ts`, +`src/server/claude-messages.ts`, `src/server/request-log-conversation.ts`, +`src/server/responses-undeclared-tool-guard.ts`, `src/providers/opencode-go-transport.ts`, +`src/types/tools.ts`. You do not own `codex-ws-exchange.ts` or `codex-ws-wire.ts` (L6) or +`codex-auth-error.ts` (L3). + +1. **#4172 — OpenCode Go sessionless requests omit `x-opencode-session`.** Expected behaviour is + fixed by the issue: every request to the canonical Go destination carries the header; identity + keeps its stable per-conversation value; no identity gets an isolated per-request value rather + than none and rather than one shared global id; an explicit header still wins. Carry PR #4184 by + `chilung-cgu` (open, not a draft, `CHANGES_REQUESTED`); read the review first. Most urgent item in + the round: upstream ended the grace period on 09/06 and now errors without the header. +2. **#4176 — a routed provider prefixes a bare Codex tool with `default.`.** **Decision: normalize + the invented prefix back at the undeclared-tool guard** — the #4181 shape, which is what the issue + states. #4181 by `chilung-cgu` is open, not a draft, `CHANGES_REQUESTED`; #4171 by `rrmlima` is an + open **draft** at `CHANGES_REQUESTED` and its unified-exec rewrite is out of round scope. Credit `rrmlima` only if you reuse code from #4171. -`core.ts` is the most contended file in the repository — four open PRs touch it. Keep the diff -minimal and do not reformat around your change. +`core.ts` is contended by four open PRs. Keep the diff minimal; do not reformat around it. -## L2 — provider quota and catalog +## L2 — provider quota and registry Worktree `~/.codex/worktrees/260911-l2/opencodex`, branch `codex/260911-l2-catalog-provider`. -Owned files: `src/providers/quota*.ts`, BigModel preset definitions, `src/codex/catalog/*` except -`effort.ts` (L4). +Owned: `src/providers/quota.ts`, `quota-types.ts`, `quota-wire.ts`, `quota-routing-cache.ts`, +`quota-key-accounts.ts`, `account-quota-disk.ts`, `registry.ts` (all under `src/providers/`). -1. **#4201 — BigModel Responses Coding Plan: missing quota probe and GLM-5.3-Flash catalog support.** - A provider-compatibility report from `bluesmilery`. #4210 by `Ingwannu` is an **open draft** at - `REVIEW_REQUIRED` restoring BigModel preset quota visibility, and it also touches - `docs-site/**/guides/providers.md`. Read it first and either build on it with attribution or stay - clear of it. +1. **#4201 — BigModel Responses Coding Plan: missing quota probe and GLM-5.3-Flash catalog support** + (reporter `bluesmilery`). **Decision: do not build on #4210.** It is an open draft by `Ingwannu` + at `REVIEW_REQUIRED` and it also edits `docs-site/src/content/docs/guides/providers.md`, which + belongs to L7. Implement #4201 independently; if your diff would overlap #4210's `quota.ts` hunks, + report that overlap to the orchestrator instead of merging the two lines of work. If the fix needs + documentation, write the wording in your report and let L7 land it. -`src/providers/quota.ts` is contended by four open PRs. Prefer a preset or catalog-side fix; if the -change genuinely belongs in `quota.ts`, keep it surgical and name the overlap in your report. +`quota.ts` is contended by four open PRs; keep the change surgical. ## L3 — Codex account pool Worktree `~/.codex/worktrees/260911-l3/opencodex`, branch `codex/260911-l3-account-pool`. -Owned files: `src/codex/account-*.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, -`model-entitlements.ts`, `src/server/responses/codex-auth-error.ts`, and one key in `src/config.ts`. +Owned: `src/codex/account-usability.ts`, `account-pause.ts`, `account-store.ts`, +`account-runtime-state.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, `model-entitlements.ts` +(all under `src/codex/`), plus `src/server/responses/codex-auth-error.ts`, +`src/server/management/oauth-account-routes.ts`, the single key `codexPool.excludedPlans` in +`src/config.ts`, and `docs-site/src/content/docs/**/guides/codex-integration.md`. 1. **#4126 — a newly created ChatGPT Free account fails Codex warmup with HTTP 404.** Carry PR #4188 by `chilung-cgu` (open **draft**, `REVIEW_REQUIRED`, reset by the readiness gate rather than - rejected on merit). It also carries eight `docs-site/**/guides/codex-integration.md` files; keep - the documentation that describes this change. + rejected). It carries `src/codex/warmup.ts`, its test, and eight `codex-integration.md` pages — + all of which you own. 2. **#4212 — an account stuck on a failed credential refresh silently drops its models.** The ask is - attribution, not new routing: when a model disappears or a request is refused because an account - is unusable, name the account and the reason where the operator already looks. The refusal string - lives at `src/server/responses/codex-auth-error.ts:35`, which is yours for this round. + attribution, not new routing. **Decision: this round covers the refusal string + (`codex-auth-error.ts:35`), the account-health surface, and the management route + (`oauth-account-routes.ts`). It does not change the L1 refusal call sites at `core.ts:2243` and + `compact.ts:296`;** if review asks for those, open a follow-up issue rather than crossing into L1. 3. **#4211 — keep Free-tier accounts out of pool selection.** **Decision: ship** - **`codexPool.excludedPlans` as an array, absent by default**, so an existing install sees no + **`codexPool.excludedPlans` as an array, absent by default,** so an existing install sees no behaviour change. Do not ship `minimumPlan`: ranking plans needs an ordering this repository does not have. @@ -135,65 +133,75 @@ Owned files: `src/codex/account-*.ts`, `plan.ts`, `plan-from-token.ts`, `warmup. Worktree `~/.codex/worktrees/260911-l4/opencodex`, branch `codex/260911-l4-service-cli`. -Owned files: `src/update/*`, `src/service*.ts`, `src/cli/*`, `src/client/*`, -`src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, `src/codex/cli-install-provenance.ts`. +Owned: directories `src/update/`, `src/cli/`, `src/client/`; files `bin/ocx.mjs`, `src/cli.ts`, +`src/service.ts`, `src/config/pending-teardown.ts`, `src/lib/bun-runtime.ts`, +`src/lib/package-tree-integrity.ts`, `src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, +`src/codex/cli-install-provenance.ts`, `docs-site/src/content/docs/getting-started/installation.md`. 1. **#4202 — global pnpm installations cannot self-update.** Carry PR #4203 by `oliver-mee` (open - **draft**, `CHANGES_REQUESTED`, **36 files**). **Decision: trim to the pnpm self-update path plus - the tests and the one documentation page that path requires.** + **draft**, `CHANGES_REQUESTED`, 36 files). **Decision: the keep-set is exactly** `bin/ocx.mjs`, + `src/cli.ts`, `src/cli/launcher-context.ts`, `src/config/pending-teardown.ts`, + `src/lib/bun-runtime.ts`, `src/lib/package-tree-integrity.ts`, `src/service.ts`, every file under + `src/update/`, the tests `tests/ci-workflows/install-scripts.test.ts`, + `tests/cli/ocx-launcher-runtime.test.ts`, `tests/cli/ocx-launcher-source.test.ts`, + `tests/update/update-badge.test.ts`, `tests/update/update-job.test.ts`, + `tests/update/update-pnpm.test.ts`, `tests/update/update-stop-first.test.ts`, the two test-layout + maps, and `docs-site/src/content/docs/getting-started/installation.md`. **Drop** `README.md`, + `structure/01_runtime.md`, `structure/06_docs-and-release.md`, + `docs-site/src/content/docs/getting-started/for-agents.md`, and + `docs-site/src/content/docs/reference/cli/lifecycle.md`. 2. **#4169 — every stop refusal is reported as a `CODEX_HOME` ownership mismatch,** hiding - `respawnable_service` and looping the operator. Carry PR #4170 by `yeongjunyoo` (open **draft**, - `REVIEW_REQUIRED`); it touches `src/lib/process-control.ts`, which you own. + `respawnable_service`. Carry PR #4170 by `yeongjunyoo` (open **draft**, `REVIEW_REQUIRED`); it + touches `src/cli/index.ts` and `src/lib/process-control.ts`, both yours. 3. **#4204 — Windows: a stale persisted CLI 0.135.0 strips max/ultra while Desktop runs 0.153.4.** - The clamp is `src/codex/catalog/effort.ts:441`. #4178 by `luvs01` is open, not a draft, with full - CI green and already owns `src/codex/cli-install-provenance.ts`: if it lands first, rebase onto - it; otherwise keep your change out of that file and say so. + The clamp is `src/codex/catalog/effort.ts:441`. #4178 by `luvs01` is open, not a draft, full CI + green, and owns `src/codex/cli-install-provenance.ts`: if it lands first, rebase onto it; + otherwise keep out of that file and say so. 4. **#4207 — the connected catalog reports success while the local Codex CLI rejects unsupported - reasoning levels.** Same clamp as #4204, which is why both live here. Client side is + reasoning levels.** Same clamp as #4204, which is why both are here; client side is `src/client/hub-client.ts:145`, `src/client/connect.ts:542`, `src/cli/connect.ts:187`. - **Decision: fail closed — when the projection is not compatible with the local client, block - readiness rather than reporting success.** + **Decision: fail closed — block local readiness rather than reporting success** when the + projection is not compatible with the local client. ## L5 — file IO and client integrations Worktree `~/.codex/worktrees/260911-l5/opencodex`, branch `codex/260911-l5-integrations-io`. -Owned files: `src/config/atomic-write.ts`, `src/integrations/*`. No open PR touches either, so this -lane can move fastest. +Owned: directory `src/integrations/`; file `src/config/atomic-write.ts`. No open PR touches either. 1. **#4197 — the DSH integration's atomic replace changes file ownership and causes `EACCES` across UIDs.** **Decision: refuse the integration write when the target exists and its owner is not the process euid, with an explicit API error. Do not relax the `0600` hardening and do not attempt - `fchown`.** A metadata-preserving replace can be proposed afterwards as its own issue. + `fchown`.** A metadata-preserving replace can be proposed later as its own issue. 2. **#4214 — add Cline as a supported client integration.** Follow the existing registry pattern in - `src/integrations/registry.ts`; do not invent a parallel mechanism. + `src/integrations/registry.ts`. ## L6 — streaming and vendor tool leakage Worktree `~/.codex/worktrees/260911-l6/opencodex`, branch `codex/260911-l6-streaming-tools`. -Owned files: `src/server/responses/codex-ws-exchange.ts`, `codex-ws-wire.ts`, `src/adapters/qoder/*`. +Owned: `src/server/responses/codex-ws-exchange.ts`, `src/server/responses/codex-ws-wire.ts`, +directory `src/adapters/qoder/`. -1. **#4191 — a long Codex thread fails only through the proxy** (WS 1006 / response prelude timeout) - while the bypass works immediately. The prelude timeout is - `src/server/responses/codex-ws-exchange.ts:214`. Reproduce first: establish what length and timing - trigger it and where the prelude budget is spent. **Decision: the only in-scope fix is to classify - and report the timeout honestly, including the close code and the cause.** A configurable prelude - budget, an SSE fallback, or a size preflight comes back as a report, not a patch. -2. **#4190 — vendor CLI agent scaffolding leaks into routed output** for `qoder`: an MCP - lazy-loading reminder and tool-call markup reach the user. The leak is in `src/adapters/qoder/`. +1. **#4191 — a long Codex thread fails only through the proxy** (WS 1006 / prelude timeout) while the + bypass works immediately. The timeout is `codex-ws-exchange.ts:214`. Reproduce first: establish + what length and timing trigger it and where the prelude budget goes. **Decision: the only in-scope + fix is to classify and report the timeout honestly, including the close code and the cause.** A + configurable budget, an SSE fallback, or a size preflight comes back as a report, not a patch. +2. **#4190 — vendor CLI agent scaffolding leaks into routed output** for `qoder`. ## L7 — documentation Worktree `~/.codex/worktrees/260911-l7/opencodex`, branch `codex/260911-l7-docs`. -Owned files: `docs-site/**/guides/providers.md` and `docs-site/**/guides/remote-hub.md`. Other lanes -own the pages that document their own changes. +Owned: `docs-site/src/content/docs/guides/providers.md`, +`docs-site/src/content/docs/guides/remote-hub.md`. You are the only lane that may edit +`providers.md`; L2 will send you wording rather than editing it. 1. **#4215 — state whether each provider login consumes a subscription allowance or bills per - token.** Write the rule per authentication mode, then one explicit line per provider that supports - both. Coordinate through the orchestrator if L2's carry of #4210 also edits `providers.md`. + token.** Write the rule per authentication mode, then one explicit line per provider supporting + both. 2. **#4200 — the remote hub guide breaks on a fresh config** (nested `ocx config set` fails when the - parent object is absent) and has no macOS data-plane TLS example. Fix the English source first; - translations are a follow-up, not a blocker. + parent object is absent) and has no macOS data-plane TLS example. English source first; + translations are a follow-up. diff --git a/devlog/_plan/260911_lane_dispatch_round/040_audit_round2.md b/devlog/_plan/260911_lane_dispatch_round/040_audit_round2.md new file mode 100644 index 0000000000..825c37945a --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/040_audit_round2.md @@ -0,0 +1,32 @@ +# Audit round 2 — reviewer verdict and dispositions + +Reviewer: a second `xai/grok-4.6` explorer subagent, read-only, fresh context. Verdict: **fail**. + +It confirmed that four round-1 findings were actually fixed (the WS split, the #4207 move, the two +PR states, the test-layout rule) and that three were only *described* as fixed. That distinction is +the reason this round exists: a disposition table is not a partition. + +| # | Finding | Disposition in revision 3 | +|---|---|---| +| 1 | #4212 also needs `src/server/management/oauth-account-routes.ts`, and its refusal call sites are `core.ts:2243` and `compact.ts:296`, which L1 owns | `oauth-account-routes.ts` is assigned to L3. **Decision: L3 does not change the L1 call sites this round.** Its #4212 scope is the refusal string, the account-health surface, and the management route; call-site attribution becomes a follow-up issue if review asks for it. | +| 2 | `docs-site/src/content/docs/guides/providers.md` was reachable by both L2 (carrying #4210) and L7 | The page belongs to **L7 only**. **Decision: L2 drops that hunk** from anything it carries and reports the needed wording to the orchestrator, who hands it to L7. | +| 3 | The #4203 keep-set was never named, so L4 was told to carry a PR that edits files it must not touch | The keep-set is now enumerated file by file below, and the five files to drop are named too. | +| 4 | L2 was still deciding maintainer policy on #4210 | **Decision: L2 does not build on #4210.** It implements #4201 independently; if its diff would overlap #4210's `quota.ts` hunks, it reports instead of merging the two lines of work. | +| 5 | Territories still used globs while claiming to be explicit lists; bare `plan.ts` matched three files; `src/providers/registry.ts` was unnamed | Ownership is now either an exact path or one named directory, and directories do not overlap. `src/codex/plan.ts` is spelled out; `src/providers/registry.ts` is assigned to L2. | +| 6 | The ledger cited a stale round-PR head and stale lane seed SHAs | The ledger is regenerated from live `git`/`gh` output after every orchestrator commit and carries the capture time. | + +## #4203 keep-set for L4 + +Keep: `bin/ocx.mjs`, `src/cli.ts`, `src/cli/launcher-context.ts`, `src/config/pending-teardown.ts`, +`src/lib/bun-runtime.ts`, `src/lib/package-tree-integrity.ts`, `src/service.ts`, every file under +`src/update/`, the tests `tests/ci-workflows/install-scripts.test.ts`, +`tests/cli/ocx-launcher-runtime.test.ts`, `tests/cli/ocx-launcher-source.test.ts`, +`tests/update/update-badge.test.ts`, `tests/update/update-job.test.ts`, +`tests/update/update-pnpm.test.ts`, `tests/update/update-stop-first.test.ts`, the two test-layout +maps, and exactly one documentation page, +`docs-site/src/content/docs/getting-started/installation.md`. + +Drop: `README.md`, `structure/01_runtime.md`, `structure/06_docs-and-release.md`, +`docs-site/src/content/docs/getting-started/for-agents.md`, +`docs-site/src/content/docs/reference/cli/lifecycle.md`. + From ccda2d23afb6a05a2151090526e71bffcfacbf6d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:49:35 +0900 Subject: [PATCH 018/126] docs(devlog): regenerate the ledger from live git and gh output --- .../260911_lane_dispatch_round/060_ledger.md | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/devlog/_plan/260911_lane_dispatch_round/060_ledger.md b/devlog/_plan/260911_lane_dispatch_round/060_ledger.md index 8f6a08e500..ffa641612c 100644 --- a/devlog/_plan/260911_lane_dispatch_round/060_ledger.md +++ b/devlog/_plan/260911_lane_dispatch_round/060_ledger.md @@ -1,31 +1,37 @@ # Round ledger -Refreshed by the orchestrator from `gh`, never from narration. `NOT RUN` is the honest value for -every local product check in this round. +Captured from live `git` and `gh` at **2026-09-10T15:49:33Z**. Every value below is a command result, not narration. ## Round PR -| PR | Head | Gate checks | Product jobs | Note | -|---|---|---|---|---| -| #4217 | `39409f9e811dc6f253a2f6bb9b92287bf2a6e73d` | `enforce-target`, `hygiene`, `label`, `resolve-pr`, `ci`, `changes`, `react-doctor`, `select windows runner` all SUCCESS | SKIPPED | Docs-only: the `changes` filter skips product legs by design | +`#4217` `555321ee5e6da84a73f8ad8eef21fb5e2f7989c6 OPEN`, base `dev`. Non-skipped checks at capture: IN_PROGRESS enforce-target, QUEUED ci, SUCCESS select windows runner, IN_PROGRESS react-doctor, SUCCESS changes, SUCCESS label, SUCCESS hygiene, SUCCESS resolve-pr, PENDING CodeRabbit. +Product legs are SKIPPED by the `changes` filter because the PR is documentation only. Each further +orchestrator commit advances this head, so the SHA above is the head at capture time and CI is +re-evaluated per push; the merge gate uses the final head, not this one. ## Lanes -| Lane | Worktree | Branch | Seed commit | Thread | PR | Head SHA | Final-head CI | State | -|---|---|---|---|---|---|---|---|---| -| L1 | `~/.codex/worktrees/260911-l1/opencodex` | `codex/260911-l1-responses-core` | `4d11c08d5` | pending | — | — | — | packet seeded | -| L2 | `~/.codex/worktrees/260911-l2/opencodex` | `codex/260911-l2-catalog-provider` | `4fa25c8dc` | pending | — | — | — | packet seeded | -| L3 | `~/.codex/worktrees/260911-l3/opencodex` | `codex/260911-l3-account-pool` | `8193ba524` | pending | — | — | — | packet seeded | -| L4 | `~/.codex/worktrees/260911-l4/opencodex` | `codex/260911-l4-service-cli` | `94b47e9a0` | pending | — | — | — | packet seeded | -| L5 | `~/.codex/worktrees/260911-l5/opencodex` | `codex/260911-l5-integrations-io` | `50ec0b5db` | pending | — | — | — | packet seeded | -| L6 | `~/.codex/worktrees/260911-l6/opencodex` | `codex/260911-l6-streaming-tools` | `582bdb457` | pending | — | — | — | packet seeded | -| L7 | `~/.codex/worktrees/260911-l7/opencodex` | `codex/260911-l7-docs` | `512a3e467` | pending | — | — | — | packet seeded | +| Lane | Worktree | Branch | Local head | PR | Head SHA | Final-head CI | State | +|---|---|---|---|---|---|---|---| +| L1 | `~/.codex/worktrees/260911-l1/opencodex` | `codex/260911-l1-responses-core` | `d2509a156da24e2f6d459103bffb73f5e6d0047f` | not yet opened | — | — | packet at revision 3, unpushed | +| L2 | `~/.codex/worktrees/260911-l2/opencodex` | `codex/260911-l2-catalog-provider` | `d72d40ae2bc72c749ec3b61f4605351de14b561e` | not yet opened | — | — | packet at revision 3, unpushed | +| L3 | `~/.codex/worktrees/260911-l3/opencodex` | `codex/260911-l3-account-pool` | `157119ecb0724feab15d9c38119b85cd8e55af93` | not yet opened | — | — | packet at revision 3, unpushed | +| L4 | `~/.codex/worktrees/260911-l4/opencodex` | `codex/260911-l4-service-cli` | `72c87ba567dcf74ad2732094b3133583a631e149` | not yet opened | — | — | packet at revision 3, unpushed | +| L5 | `~/.codex/worktrees/260911-l5/opencodex` | `codex/260911-l5-integrations-io` | `08ce233806727f3a76709bcc810581151e98dc2d` | not yet opened | — | — | packet at revision 3, unpushed | +| L6 | `~/.codex/worktrees/260911-l6/opencodex` | `codex/260911-l6-streaming-tools` | `9942ff6b24ff09ebd55f54196196db62137d54b7` | not yet opened | — | — | packet at revision 3, unpushed | +| L7 | `~/.codex/worktrees/260911-l7/opencodex` | `codex/260911-l7-docs` | `d5758f235c87d96164c7d5e85cf62c4cc921741e` | not yet opened | — | — | packet at revision 3, unpushed | -Base freeze for every lane: `6d3ad12e3`. +Base freeze for every lane: `6d3ad12e3`. Lane branches are local until their thread pushes. ## Local checks `bun test`, `bun run test`, `bun run test:changed`, `bun run typecheck`, `bun run build:gui`, `bun install`: **NOT RUN** in this round, by operator instruction. Hosted CI on each exact pushed -head is the only product evidence this round will cite. +head is the only product evidence this round cites. + +## Audit history + +Round 1 (`030_audit_round1.md`): **fail**, seven findings, all folded in. +Round 2 (`040_audit_round2.md`): **fail**, six findings, all folded in; it confirmed four round-1 +fixes were real and three were only described as fixed. From 33d4ee2a0b46aa5171744820b3040ba4f9cd1161 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 01:06:23 +0900 Subject: [PATCH 019/126] docs(devlog): fold audit round 3 and record the one rejected finding --- .../010_lane_partition.md | 7 ++++--- .../020_lane_packets.md | 20 +++++++++++++------ .../050_audit_round3.md | 19 ++++++++++++++++++ 3 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 devlog/_plan/260911_lane_dispatch_round/050_audit_round3.md diff --git a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md index ef23a3f531..8c942206ad 100644 --- a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md +++ b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md @@ -32,7 +32,8 @@ Collision-free PRs, touching no file any other open PR touches: #4062, #4104, #4 `src/server/responses/compact.ts`, `src/server/responses/policy-fallback.ts`, `src/server/chat-completions.ts`, `src/server/claude-messages.ts`, `src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, -`src/providers/opencode-go-transport.ts`, `src/types/tools.ts`. Stack: #4172 → #4176. +`src/providers/opencode-go-transport.ts`, `src/types/tools.ts`, +`docs-site/src/content/docs/reference/configuration/providers.md`. Stack: #4172 → #4176. **L2** `codex/260911-l2-catalog-provider` — `src/providers/quota.ts`, `src/providers/quota-types.ts`, `src/providers/quota-wire.ts`, @@ -44,7 +45,8 @@ Collision-free PRs, touching no file any other open PR touches: #4062, #4104, #4 `src/codex/plan.ts`, `src/codex/plan-from-token.ts`, `src/codex/warmup.ts`, `src/codex/model-entitlements.ts`, `src/server/responses/codex-auth-error.ts`, `src/server/management/oauth-account-routes.ts`, the single key `codexPool.excludedPlans` in -`src/config.ts`, and `docs-site/src/content/docs/**/guides/codex-integration.md`. +`src/config.ts`, and `docs-site/src/content/docs/guides/codex-integration.md` and its seven locale copies under +`docs-site/src/content/docs/{fr,ja,ko,ru,tr,zh-cn,zh-tw}/guides/codex-integration.md`. Stack: #4126 → #4212 → #4211. **L4** `codex/260911-l4-service-cli` — directories `src/update/`, `src/cli/`, `src/client/`; files @@ -71,4 +73,3 @@ Stack: #4126 → #4212 → #4211. - `src/config.ts`: only L3, and only `codexPool.excludedPlans`. - `docs-site/src/content/docs/guides/providers.md`: L7 only. A lane whose carried PR edits it drops that hunk and reports the wording to the orchestrator. - diff --git a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md index 654f643546..6c6de9e57a 100644 --- a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md +++ b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md @@ -72,7 +72,8 @@ Owned: `src/server/responses/core.ts`, `src/server/responses/compact.ts`, `src/server/responses/policy-fallback.ts`, `src/server/chat-completions.ts`, `src/server/claude-messages.ts`, `src/server/request-log-conversation.ts`, `src/server/responses-undeclared-tool-guard.ts`, `src/providers/opencode-go-transport.ts`, -`src/types/tools.ts`. You do not own `codex-ws-exchange.ts` or `codex-ws-wire.ts` (L6) or +`src/types/tools.ts`, and `docs-site/src/content/docs/reference/configuration/providers.md` (the page +#4184 already edits). You do not own `codex-ws-exchange.ts` or `codex-ws-wire.ts` (L6) or `codex-auth-error.ts` (L3). 1. **#4172 — OpenCode Go sessionless requests omit `x-opencode-session`.** Expected behaviour is @@ -113,7 +114,8 @@ Owned: `src/codex/account-usability.ts`, `account-pause.ts`, `account-store.ts`, `account-runtime-state.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, `model-entitlements.ts` (all under `src/codex/`), plus `src/server/responses/codex-auth-error.ts`, `src/server/management/oauth-account-routes.ts`, the single key `codexPool.excludedPlans` in -`src/config.ts`, and `docs-site/src/content/docs/**/guides/codex-integration.md`. +`src/config.ts`, and `docs-site/src/content/docs/guides/codex-integration.md` and its seven locale copies under +`docs-site/src/content/docs/{fr,ja,ko,ru,tr,zh-cn,zh-tw}/guides/codex-integration.md`. 1. **#4126 — a newly created ChatGPT Free account fails Codex warmup with HTTP 404.** Carry PR #4188 by `chilung-cgu` (open **draft**, `REVIEW_REQUIRED`, reset by the readiness gate rather than @@ -127,7 +129,9 @@ Owned: `src/codex/account-usability.ts`, `account-pause.ts`, `account-store.ts`, 3. **#4211 — keep Free-tier accounts out of pool selection.** **Decision: ship** **`codexPool.excludedPlans` as an array, absent by default,** so an existing install sees no behaviour change. Do not ship `minimumPlan`: ranking plans needs an ordering this repository does - not have. + not have. **Decision: this round ships selection only.** If the dashboard or CLI display the issue + also asks for needs `src/cli/account.ts`, a GUI component, or a locale key, stop and report; write + `Refs #4211` rather than `Closes #4211` when the display half is not included. ## L4 — service, update, CLI, and connected client @@ -152,7 +156,9 @@ Owned: directories `src/update/`, `src/cli/`, `src/client/`; files `bin/ocx.mjs` `docs-site/src/content/docs/reference/cli/lifecycle.md`. 2. **#4169 — every stop refusal is reported as a `CODEX_HOME` ownership mismatch,** hiding `respawnable_service`. Carry PR #4170 by `yeongjunyoo` (open **draft**, `REVIEW_REQUIRED`); it - touches `src/cli/index.ts` and `src/lib/process-control.ts`, both yours. + touches `src/cli/index.ts` and `src/lib/process-control.ts`, both yours, plus its two tests + `tests/lib/process-control-graceful.test.ts` and `tests/providers/xai/grok-lifecycle.test.ts`, + which you keep. 3. **#4204 — Windows: a stale persisted CLI 0.135.0 strips max/ultra while Desktop runs 0.153.4.** The clamp is `src/codex/catalog/effort.ts:441`. #4178 by `luvs01` is open, not a draft, full CI green, and owns `src/codex/cli-install-provenance.ts`: if it lands first, rebase onto it; @@ -188,7 +194,10 @@ directory `src/adapters/qoder/`. what length and timing trigger it and where the prelude budget goes. **Decision: the only in-scope fix is to classify and report the timeout honestly, including the close code and the cause.** A configurable budget, an SSE fallback, or a size preflight comes back as a report, not a patch. -2. **#4190 — vendor CLI agent scaffolding leaks into routed output** for `qoder`. +2. **#4190 — vendor CLI agent scaffolding leaks into routed output** for `qoder`. **Decision: fix it + inside `src/adapters/qoder/` by sanitizing the vendor scaffolding out of routed output, failing + closed when the shape is unrecognized.** If the fix needs `src/adapters/coding-agent/protocol.ts`, + which no lane owns, stop and report. ## L7 — documentation @@ -204,4 +213,3 @@ Owned: `docs-site/src/content/docs/guides/providers.md`, 2. **#4200 — the remote hub guide breaks on a fresh config** (nested `ocx config set` fails when the parent object is absent) and has no macOS data-plane TLS example. English source first; translations are a follow-up. - diff --git a/devlog/_plan/260911_lane_dispatch_round/050_audit_round3.md b/devlog/_plan/260911_lane_dispatch_round/050_audit_round3.md new file mode 100644 index 0000000000..c95a8fe43f --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/050_audit_round3.md @@ -0,0 +1,19 @@ +# Audit round 3 — reviewer verdict and dispositions + +Reviewer: a third `xai/grok-4.6` explorer subagent, read-only, fresh context. Verdict: **near-pass**, +with the instruction "Dispatch." It confirmed that all six round-2 items are fixed in the tree rather +than narrated, and that the seven seeded packets are byte-equal to the round document. + +| # | Finding | Disposition | +|---|---|---| +| 1 | #4184 also edits `docs-site/src/content/docs/reference/configuration/providers.md`, which no lane owned | Assigned to L1. It is the page that documents L1's own change, and it is not L7's `guides/providers.md`. | +| 2 | #4211 asks for dashboard and CLI display too, which the packet left unscoped | **Decision: the round ships selection only.** L3 stops and reports if display needs `src/cli/account.ts`, a GUI component, or a locale key, and writes `Refs #4211` instead of `Closes #4211` when the display half is absent. | +| 3 | #4190 had no how-decision, unlike #4191 | **Decision: sanitize inside `src/adapters/qoder/` and fail closed on an unrecognized shape.** L6 stops if it needs `src/adapters/coding-agent/protocol.ts`. | +| 4 | Carrying #4184 conflicts with the issue shape because of its request-scoped ephemeral lane | **Rejected.** #4172 states the opposite: "Requests with no identity should receive an isolated per-request value rather than being sent unheaderised or sharing one value." #4184's request-scoped lane is that shape, not a deviation. Recorded rather than folded, because folding a wrong finding would send L1 in the wrong direction. | +| 5 | The #4170 keep-set omitted its two tests | Added: `tests/lib/process-control-graceful.test.ts` and `tests/providers/xai/grok-lifecycle.test.ts`. | +| — | Residual glob `docs-site/**/guides/codex-integration.md` | Expanded to the English page and its seven named locale copies. | + +The reviewer also noted that `020` writes a bare `plan.ts` under a "all under `src/codex/`" heading +while four `plan.ts` files exist in the repository. `010_lane_partition.md` spells +`src/codex/plan.ts` and is the authoritative list, which the packet header states. + From 538668bb0b1c8cc9f28737df3dd574af85a733b3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 01:07:57 +0900 Subject: [PATCH 020/126] docs(devlog): add the per-lane dispatch handoff --- .../070_dispatch_handoff.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 devlog/_plan/260911_lane_dispatch_round/070_dispatch_handoff.md diff --git a/devlog/_plan/260911_lane_dispatch_round/070_dispatch_handoff.md b/devlog/_plan/260911_lane_dispatch_round/070_dispatch_handoff.md new file mode 100644 index 0000000000..402c4fb6ce --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/070_dispatch_handoff.md @@ -0,0 +1,148 @@ +# Dispatch handoff + +A thread is opened by the operator in the Codex app against the lane's worktree; an agent cannot +create one. Everything else is ready: the worktree exists on its branch, the packet is committed on +that branch, and the round is recorded in PR #4217. + +Paste the matching block into a new thread whose working directory is the lane worktree. + +## L1 + +```text +You are lane L1 of the 260911 dispatch round. Your worktree is +~/.codex/worktrees/260911-l1/opencodex, already on branch codex/260911-l1-responses-core, cut from origin/dev 6d3ad12e3. + +Read devlog/_plan/260911_l1_responses_core/000_packet.md first and follow it exactly. It is your contract: +owned paths, the decisions already made for you, the rules, and the report format. + +Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4172 then #4176. + +Non-negotiable: never run the local product suite, typecheck, build, or install - report them as +NOT RUN. Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. Push with +--no-verify. Use unlimited read-only xai/grok-4.6 subagents to reproduce, to read call sites, and to +review your own staged diff before you push; fold their findings in rather than arguing with them. +Stay inside your owned paths, even when a carried PR touches more. Do not merge - when your last PR +is green, report and stop. +``` + +## L2 + +```text +You are lane L2 of the 260911 dispatch round. Your worktree is +~/.codex/worktrees/260911-l2/opencodex, already on branch codex/260911-l2-catalog-provider, cut from origin/dev 6d3ad12e3. + +Read devlog/_plan/260911_l2_catalog_provider/000_packet.md first and follow it exactly. It is your contract: +owned paths, the decisions already made for you, the rules, and the report format. + +Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4201. + +Non-negotiable: never run the local product suite, typecheck, build, or install - report them as +NOT RUN. Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. Push with +--no-verify. Use unlimited read-only xai/grok-4.6 subagents to reproduce, to read call sites, and to +review your own staged diff before you push; fold their findings in rather than arguing with them. +Stay inside your owned paths, even when a carried PR touches more. Do not merge - when your last PR +is green, report and stop. +``` + +## L3 + +```text +You are lane L3 of the 260911 dispatch round. Your worktree is +~/.codex/worktrees/260911-l3/opencodex, already on branch codex/260911-l3-account-pool, cut from origin/dev 6d3ad12e3. + +Read devlog/_plan/260911_l3_account_pool/000_packet.md first and follow it exactly. It is your contract: +owned paths, the decisions already made for you, the rules, and the report format. + +Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4126 then #4212 then #4211. + +Non-negotiable: never run the local product suite, typecheck, build, or install - report them as +NOT RUN. Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. Push with +--no-verify. Use unlimited read-only xai/grok-4.6 subagents to reproduce, to read call sites, and to +review your own staged diff before you push; fold their findings in rather than arguing with them. +Stay inside your owned paths, even when a carried PR touches more. Do not merge - when your last PR +is green, report and stop. +``` + +## L4 + +```text +You are lane L4 of the 260911 dispatch round. Your worktree is +~/.codex/worktrees/260911-l4/opencodex, already on branch codex/260911-l4-service-cli, cut from origin/dev 6d3ad12e3. + +Read devlog/_plan/260911_l4_service_cli/000_packet.md first and follow it exactly. It is your contract: +owned paths, the decisions already made for you, the rules, and the report format. + +Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4202 then #4169 then #4204 then #4207. + +Non-negotiable: never run the local product suite, typecheck, build, or install - report them as +NOT RUN. Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. Push with +--no-verify. Use unlimited read-only xai/grok-4.6 subagents to reproduce, to read call sites, and to +review your own staged diff before you push; fold their findings in rather than arguing with them. +Stay inside your owned paths, even when a carried PR touches more. Do not merge - when your last PR +is green, report and stop. +``` + +## L5 + +```text +You are lane L5 of the 260911 dispatch round. Your worktree is +~/.codex/worktrees/260911-l5/opencodex, already on branch codex/260911-l5-integrations-io, cut from origin/dev 6d3ad12e3. + +Read devlog/_plan/260911_l5_integrations_io/000_packet.md first and follow it exactly. It is your contract: +owned paths, the decisions already made for you, the rules, and the report format. + +Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4197 then #4214. + +Non-negotiable: never run the local product suite, typecheck, build, or install - report them as +NOT RUN. Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. Push with +--no-verify. Use unlimited read-only xai/grok-4.6 subagents to reproduce, to read call sites, and to +review your own staged diff before you push; fold their findings in rather than arguing with them. +Stay inside your owned paths, even when a carried PR touches more. Do not merge - when your last PR +is green, report and stop. +``` + +## L6 + +```text +You are lane L6 of the 260911 dispatch round. Your worktree is +~/.codex/worktrees/260911-l6/opencodex, already on branch codex/260911-l6-streaming-tools, cut from origin/dev 6d3ad12e3. + +Read devlog/_plan/260911_l6_streaming_tools/000_packet.md first and follow it exactly. It is your contract: +owned paths, the decisions already made for you, the rules, and the report format. + +Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4191 then #4190. + +Non-negotiable: never run the local product suite, typecheck, build, or install - report them as +NOT RUN. Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. Push with +--no-verify. Use unlimited read-only xai/grok-4.6 subagents to reproduce, to read call sites, and to +review your own staged diff before you push; fold their findings in rather than arguing with them. +Stay inside your owned paths, even when a carried PR touches more. Do not merge - when your last PR +is green, report and stop. +``` + +## L7 + +```text +You are lane L7 of the 260911 dispatch round. Your worktree is +~/.codex/worktrees/260911-l7/opencodex, already on branch codex/260911-l7-docs, cut from origin/dev 6d3ad12e3. + +Read devlog/_plan/260911_l7_docs/000_packet.md first and follow it exactly. It is your contract: +owned paths, the decisions already made for you, the rules, and the report format. + +Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4215 then #4200. + +Non-negotiable: never run the local product suite, typecheck, build, or install - report them as +NOT RUN. Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. Push with +--no-verify. Use unlimited read-only xai/grok-4.6 subagents to reproduce, to read call sites, and to +review your own staged diff before you push; fold their findings in rather than arguing with them. +Stay inside your owned paths, even when a carried PR touches more. Do not merge - when your last PR +is green, report and stop. +``` + +## What the orchestrator does with the returns + +Nothing lands on a lane's authority. The orchestrator refreshes each lane's PR head, run id, and +review state from `gh`, resolves the append-only conflicts in the two test-layout maps, and merges +one lane at a time, each only when hosted CI is green on that exact head, with fetched `origin/dev` +ancestry as the landing proof. + From 0f61ab6ed2aca04f0a6411a7cf427dc5fa192a6e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 01:19:47 +0900 Subject: [PATCH 021/126] docs(devlog): record the published lane branches and the wp2 publish plan --- .../260911_lane_dispatch_round/060_ledger.md | 48 +++++++++++-------- .../080_wp2_publish_plan.md | 46 ++++++++++++++++++ 2 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 devlog/_plan/260911_lane_dispatch_round/080_wp2_publish_plan.md diff --git a/devlog/_plan/260911_lane_dispatch_round/060_ledger.md b/devlog/_plan/260911_lane_dispatch_round/060_ledger.md index ffa641612c..0141b94cbe 100644 --- a/devlog/_plan/260911_lane_dispatch_round/060_ledger.md +++ b/devlog/_plan/260911_lane_dispatch_round/060_ledger.md @@ -1,27 +1,34 @@ # Round ledger -Captured from live `git` and `gh` at **2026-09-10T15:49:33Z**. Every value below is a command result, not narration. +Captured from live `git ls-remote` and `gh` at **2026-09-10T16:19:21Z**. Every value is a command +result, not narration. -## Round PR +## Round unit -`#4217` `555321ee5e6da84a73f8ad8eef21fb5e2f7989c6 OPEN`, base `dev`. Non-skipped checks at capture: IN_PROGRESS enforce-target, QUEUED ci, SUCCESS select windows runner, IN_PROGRESS react-doctor, SUCCESS changes, SUCCESS label, SUCCESS hygiene, SUCCESS resolve-pr, PENDING CodeRabbit. -Product legs are SKIPPED by the `changes` filter because the PR is documentation only. Each further -orchestrator commit advances this head, so the SHA above is the head at capture time and CI is -re-evaluated per push; the merge gate uses the final head, not this one. +PR #4217 merged into `dev` at `2026-09-10T16:13:35Z` from head +`538668bb0b1c8cc9f28737df3dd574af85a733b3`, with `ci`, `enforce-target`, `changes`, +`select windows runner`, `react-doctor`, `label`, `hygiene`, and `resolve-pr` all SUCCESS and the +product legs SKIPPED by the `changes` filter on a documentation-only PR. Landing proven by +`git merge-base --is-ancestor 538668bb0 origin/dev` after fetching; `dev` was `0aa685031` at that +moment. ## Lanes -| Lane | Worktree | Branch | Local head | PR | Head SHA | Final-head CI | State | -|---|---|---|---|---|---|---|---| -| L1 | `~/.codex/worktrees/260911-l1/opencodex` | `codex/260911-l1-responses-core` | `d2509a156da24e2f6d459103bffb73f5e6d0047f` | not yet opened | — | — | packet at revision 3, unpushed | -| L2 | `~/.codex/worktrees/260911-l2/opencodex` | `codex/260911-l2-catalog-provider` | `d72d40ae2bc72c749ec3b61f4605351de14b561e` | not yet opened | — | — | packet at revision 3, unpushed | -| L3 | `~/.codex/worktrees/260911-l3/opencodex` | `codex/260911-l3-account-pool` | `157119ecb0724feab15d9c38119b85cd8e55af93` | not yet opened | — | — | packet at revision 3, unpushed | -| L4 | `~/.codex/worktrees/260911-l4/opencodex` | `codex/260911-l4-service-cli` | `72c87ba567dcf74ad2732094b3133583a631e149` | not yet opened | — | — | packet at revision 3, unpushed | -| L5 | `~/.codex/worktrees/260911-l5/opencodex` | `codex/260911-l5-integrations-io` | `08ce233806727f3a76709bcc810581151e98dc2d` | not yet opened | — | — | packet at revision 3, unpushed | -| L6 | `~/.codex/worktrees/260911-l6/opencodex` | `codex/260911-l6-streaming-tools` | `9942ff6b24ff09ebd55f54196196db62137d54b7` | not yet opened | — | — | packet at revision 3, unpushed | -| L7 | `~/.codex/worktrees/260911-l7/opencodex` | `codex/260911-l7-docs` | `d5758f235c87d96164c7d5e85cf62c4cc921741e` | not yet opened | — | — | packet at revision 3, unpushed | +All seven branches are published on `origin`. Each is exactly one commit on `6d3ad12e3` touching only +its own packet file, verified before the push and re-read from the remote after it. -Base freeze for every lane: `6d3ad12e3`. Lane branches are local until their thread pushes. +| Lane | Worktree | Branch | Remote head | PR | Final-head CI | State | +|---|---|---|---|---|---|---| +| L1 | `~/.codex/worktrees/260911-l1/opencodex` | `codex/260911-l1-responses-core` | `29c342da74f89f9b6f21b501bef99fcf53a1d536` | none open | — | thread not opened yet | +| L2 | `~/.codex/worktrees/260911-l2/opencodex` | `codex/260911-l2-catalog-provider` | `b28f06ee04603698c2dc2bf134c80d3b7796c480` | none open | — | thread not opened yet | +| L3 | `~/.codex/worktrees/260911-l3/opencodex` | `codex/260911-l3-account-pool` | `6fd401636a6e1f26f8f8f6067c471db7900722aa` | none open | — | thread not opened yet | +| L4 | `~/.codex/worktrees/260911-l4/opencodex` | `codex/260911-l4-service-cli` | `f48cede91719257f8ae1565f8907b1a0b09df7dd` | none open | — | thread not opened yet | +| L5 | `~/.codex/worktrees/260911-l5/opencodex` | `codex/260911-l5-integrations-io` | `a7a92089cbbd7da539a71d74f7d8abda4724ba41` | none open | — | thread not opened yet | +| L6 | `~/.codex/worktrees/260911-l6/opencodex` | `codex/260911-l6-streaming-tools` | `3760f81fddc5b7af6d742c1216c894838c762ee9` | none open | — | thread not opened yet | +| L7 | `~/.codex/worktrees/260911-l7/opencodex` | `codex/260911-l7-docs` | `cd5dcd6a253109e60f6f2fb30ac08a1692c43a73` | none open | — | thread not opened yet | + +The `PR` column is a `gh pr list --head` snapshot at the capture time above. A lane thread that opens +its pull request afterwards supersedes this column; refresh it rather than trusting it. ## Local checks @@ -31,7 +38,10 @@ head is the only product evidence this round cites. ## Audit history -Round 1 (`030_audit_round1.md`): **fail**, seven findings, all folded in. -Round 2 (`040_audit_round2.md`): **fail**, six findings, all folded in; it confirmed four round-1 -fixes were real and three were only described as fixed. +| Round | Verdict | Outcome | +|---|---|---| +| 1 (`030`) | fail | Seven findings, all folded in. | +| 2 (`040`) | fail | Six findings; it separated four real round-1 fixes from three that were only described as fixed. | +| 3 (`050`) | near-pass | Five findings: four folded, one rebutted with the issue text that disproved it. | +| wp2 (`080`) | near-pass | Three acceptance-criteria gaps folded before the pushes. | diff --git a/devlog/_plan/260911_lane_dispatch_round/080_wp2_publish_plan.md b/devlog/_plan/260911_lane_dispatch_round/080_wp2_publish_plan.md new file mode 100644 index 0000000000..9154622cba --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/080_wp2_publish_plan.md @@ -0,0 +1,46 @@ +# wp2 — publish the lane branches + +wp1 closed with the round unit merged into `dev` (`0aa685031`, PR #4217) and seven lane worktrees +holding a committed packet. The packets exist only locally, which leaves two real gaps: a lane thread +cannot open a pull request until its branch exists on `origin`, and a lost worktree would take its +packet with it. + +## What this phase does + +1. Push each of the seven lane branches to `origin` with `--no-verify` and + `git -c core.hooksPath=/dev/null`. Audit confirmed each branch is exactly one commit on top of + `6d3ad12e3` touching only its own packet file, and that no `codex/260911-l*` ref exists on + `origin` yet, so no push overwrites anything. +2. Verify each pushed ref from the remote with `git ls-remote`, not from the local tree. +3. Record the pushed heads in `060_ledger.md` on the follow-up branch + `codex/260911-round-ledger-1`, and open that as a pull request targeting `dev` with the full PR + template, since `enforce-target` rejects a thin description. + +## Acceptance + +- `git ls-remote origin 'refs/heads/codex/260911-l*'` lists all seven refs, and each remote SHA equals + the local head of its worktree at `~/.codex/worktrees/260911-l1/opencodex` through + `~/.codex/worktrees/260911-l7/opencodex`. +- Each pushed commit's diff against `6d3ad12e3` contains only its own + `devlog/_plan/260911_l_/000_packet.md`. +- `060_ledger.md` on `codex/260911-round-ledger-1` carries the live remote head per lane, replacing + the stale seed SHAs, plus the `gh pr list --head` snapshot and its capture time. A lane pull request + opened after that capture supersedes the snapshot; the ledger says so rather than pretending the + value is durable. + +## Out of scope + +No lane implementation. No pull request for a lane branch: a lane thread opens its own so the +description and checklist come from the thread that did the work. No merge of a lane branch. + +## Why this is safe + +Pushing a `codex/260911-l*` ref touches none of the protected branches, and `enforce-target` is a +pull-request gate rather than a push gate. The only policy surface in this phase is the ledger pull +request, which targets `dev` and fills the template. + +## Local checks + +`NOT RUN`, as everywhere in this round. The pushed branches carry documentation only, so hosted CI +has nothing to run on them until a lane pushes code. + From 6a3774c157c28b0f0f82675a6784f3d2476b21f1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 01:20:39 +0900 Subject: [PATCH 022/126] docs(devlog): record the wp2 push execution and its remote verification --- .../090_wp2_execution.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 devlog/_plan/260911_lane_dispatch_round/090_wp2_execution.md diff --git a/devlog/_plan/260911_lane_dispatch_round/090_wp2_execution.md b/devlog/_plan/260911_lane_dispatch_round/090_wp2_execution.md new file mode 100644 index 0000000000..6ddfb29c2a --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/090_wp2_execution.md @@ -0,0 +1,34 @@ +# wp2 — execution record + +## What ran + +Seven pushes, each from its own worktree, each +`git -c core.hooksPath=/dev/null push --no-verify -u origin `. The hooks path override is not +decoration: this repository's hooks can start a GUI install, typecheck, and build, which the +no-local-suite rule forbids. + +| Lane | Branch | Remote head after push | +|---|---|---| +| L1 | `codex/260911-l1-responses-core` | `29c342da74f89f9b6f21b501bef99fcf53a1d536` | +| L2 | `codex/260911-l2-catalog-provider` | `b28f06ee04603698c2dc2bf134c80d3b7796c480` | +| L3 | `codex/260911-l3-account-pool` | `6fd401636a6e1f26f8f8f6067c471db7900722aa` | +| L4 | `codex/260911-l4-service-cli` | `f48cede91719257f8ae1565f8907b1a0b09df7dd` | +| L5 | `codex/260911-l5-integrations-io` | `a7a92089cbbd7da539a71d74f7d8abda4724ba41` | +| L6 | `codex/260911-l6-streaming-tools` | `3760f81fddc5b7af6d742c1216c894838c762ee9` | +| L7 | `codex/260911-l7-docs` | `cd5dcd6a253109e60f6f2fb30ac08a1692c43a73` | + +Every remote SHA was read back with `git ls-remote origin 'refs/heads/codex/260911-l*'` and matched +the local head of its worktree. + +## Ledger pull request + +`codex/260911-round-ledger-1`, cut from `origin/dev` after the round unit landed, carries the refreshed +ledger and the wp2 plan. Opened as PR #4220 against `dev` with the template filled, because +`enforce-target` rejects a thin description. + +## What did not happen + +No lane pull request was opened by this phase. A lane thread opens its own so the description and the +readiness checklist come from the thread that did the work. No lane branch was merged. No local +product suite, typecheck, build, or install ran: `NOT RUN`. + From 9f550ae5681c4cb3635830c82706357c4c606398 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 01:47:16 +0900 Subject: [PATCH 023/126] docs(devlog): mechanize the lane tracking refresh and fix the wp4 merge choreography --- .../100_wp3_plan.md | 136 ++++++++++++++++++ .../110_tracking_refresh.md | 52 +++++++ 2 files changed, 188 insertions(+) create mode 100644 devlog/_plan/260911_lane_dispatch_round/100_wp3_plan.md create mode 100644 devlog/_plan/260911_lane_dispatch_round/110_tracking_refresh.md diff --git a/devlog/_plan/260911_lane_dispatch_round/100_wp3_plan.md b/devlog/_plan/260911_lane_dispatch_round/100_wp3_plan.md new file mode 100644 index 0000000000..c4c7c87959 --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/100_wp3_plan.md @@ -0,0 +1,136 @@ +# wp3 — tracking protocol (revision 3, after two wp3 audit rounds) + +The seven lane branches are published, so tracking has already begun: their remote heads are live +values today, before any thread pushes. What is missing is that the ledger must be regenerated by +commands whose flags are named. Audit round 2 of the round unit caught a hand-written ledger citing a +stale head; wp3 audit round 1 caught this plan trusting commands that do not return what it claimed; +wp3 audit round 2 caught the CI rule passing vacuously when no run exists at all. + +## Corrected command set + +- Remote head: `git ls-remote origin refs/heads/`. +- Pull request: `gh pr list -R lidge-jun/opencodex --head --state open --json` + `number,headRefOid,reviewDecision,mergeStateStatus`. Use `list`, never `view`: `gh pr view ` + exits 1 when no pull request exists, which breaks a `set -e` loop, while `list` returns `[]` and + exits 0. Treat `[]` as "no PR yet" and never let `jq '.[0].number'` print `null` into a table cell. +- Workflow runs: `gh run list -R lidge-jun/opencodex --commit --json` + `workflowName,conclusion,status,databaseId,headSha,event`. `--commit` binds the SHA; `--branch` does + not. Do not read `.[0]`: the list is newest-first across all workflows, so the first row is often a + cancelled hygiene run while the product workflow is still `action_required`. + +## How CI is judged + +Reduce the run list to the newest run per `workflowName` for that exact SHA, by highest +`databaseId`. Then: + +- An empty list is **not** green. It means no run exists for that commit, which is the state every + lane branch is in right now. Absence of evidence is recorded as `no CI`, never as success. +- `success` is the only passing conclusion. `cancelled`, `skipped`, `action_required`, `failure`, + `timed_out`, and an empty conclusion with `status` other than `completed` are all not-green. + `action_required` in particular is the normal state of a fork run awaiting maintainer approval. +- The workflows that must be present and `success` depend on what the pull request touches, because + `ci.yml` gates its product legs behind a `changes` filter. A pull request touching anything under + `src/`, `gui/`, `tests/`, or `scripts/` must show `Cross-platform CI` as `success` on that SHA; one + touching `gui/` must also show `React Doctor`; one touching the service paths must also show + `Service lifecycle`. `PR hygiene`, `PR Labeler`, and `Enforce PR target branch` are required on + every pull request. +- A documentation-only pull request legitimately shows the product legs as skipped, as #4217 and + #4220 did. That is recorded as "product legs skipped by the changes filter", not as green product + CI, so the distinction survives into the ledger. +- `mergeStateStatus` is not a CI verdict. It is frequently `UNKNOWN` on a perfectly mergeable pull + request, as it was on #4210, #4203, and on #4220 after it merged. + +## What this phase produces + +1. `110_tracking_refresh.md` — the refresh block written out with the flags above plus these reading + rules, so a later session cannot re-derive a softer version. +2. One executed snapshot with its capture time, proving the block runs against the live repository. + The snapshot says plainly that any lane pull request opened afterwards supersedes it. +3. The wp4 merge choreography below. + +## Merge choreography for wp4 + +Order: L5, L3, L4, L2, L6, L7, L1. L1 goes last because `core.ts` is contended by four open pull +requests and every other lane would rebase behind it; L5 goes first because it has the least +contention inside this round, though it is not collision-free in the repository at large — open draft +#3833 also edits `src/integrations/registry.ts`. Within a lane, a child lands only after its parent +and is retargeted from the parent branch to `dev` first. + +Gate for each merge, re-queried at merge time rather than read from the snapshot: + +1. `git ls-remote origin refs/heads/` equals the pull request's `headRefOid`. +2. CI on that exact SHA passes the rule above, including the presence requirement. +3. `scripts/ci/assert-mergeable-review.sh [--maintainer-integration] ` exits zero. That script, + not `reviewDecision`, is the review gate: it requires each maintainer's *latest* review bound to + the exact current head and fails closed on any API error, which a `reviewDecision` read does not. +4. A carried or reimplemented pull request has its `Co-authored-by` trailer present in a branch + commit, verified with `git log`, before the merge rather than after. +5. The base is still `dev`, re-read after the previous merge in the series. + +The merge itself is `gh pr merge --merge` or `--squash` — never rebase — with +`--match-head-commit `, so a push landing between the gate and the merge aborts the merge +instead of laundering stale evidence. When the maintainer-integration path is used, record the +decision and the exact-head evidence with it. + +Landing proof: `git fetch origin` and then ancestry of the merge commit, or tree equality when the +pull request was squashed, because a squash leaves the pull request head outside `origin/dev` +ancestry. The merge command's own output is never the proof. + +Test-layout conflicts: `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` +are resolved as a union of both sides' entries, never `--ours` or `--theirs`, since dropping a side +silently unregisters another lane's test file. + +## Acceptance + +- The refresh block runs with exit 0 today, when no lane has a pull request and no run exists, and + prints one line per lane with the remote head, `no PR`, and `no CI`. +- Every printed field comes from one of the three commands above. +- The snapshot carries its capture time and the superseding note. +- The choreography names the order, the five gate conditions, the head-pinned merge command, the + landing proof for both merge and squash, and the union rule for the two test-layout maps. + +## Out of scope + +No lane implementation, no lane pull request, no merge. Contributor pull requests outside this round +stay outside it: merging them is a review decision the operator has not delegated. + +## Local checks + +`NOT RUN`. The refresh block only reads. + + +## Amendments from wp3 audit round 3 + +The CI rule above was an allowlist, which is the wrong shape: it can only catch a failure it already +named. It is replaced by its inverse. + +- **Every** newest-per-`workflowName` run on the exact head must be `success`. A red workflow outside + any named set fails the lane; nothing passes by not being on a list. +- Only runs whose `event` is `pull_request` or `push` count toward the gate. A later + `workflow_dispatch` success must not outrank a failed `pull_request` run for the same workflow, + which is why `event` is fetched. +- Presence, corrected against the workflow files rather than from memory: `React Doctor` has no path + filter and runs on every pull request, so it must always be present. `Cross-platform CI` is gated by + the `changes` `ci` filter in `.github/workflows/ci.yml`, which covers more than `src/`, `gui/`, + `tests/`, and `scripts/` — it also includes `bin/**`, `docker/**`, `assets/**`, `package.json`, and + selected workflow files. `Service lifecycle` triggers on `src/service.ts`, `src/cli.ts`, + `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, and its own two workflow + files, which is why L4 in particular must read the filter rather than guess. +- The authoritative per-pull-request required set is `gh pr checks `, because GitHub already + resolved the filters for that head. `gh run list --commit` supplies the workflow-level conclusion + and the `event`; `gh pr checks` supplies the check-run granularity. Use both. +- "Product legs skipped" is a check-run fact, not a workflow fact: #4220 showed `Cross-platform CI` + and `React Doctor` as workflow-level `success` with the jobs skipped inside. Read it from + `gh pr checks`, never from `gh run list`. + +Two corrections to the merge gate: + +- `scripts/ci/assert-mergeable-review.sh` was described too loosely. Its default path requires one + non-author maintainer approval bound to the exact current head and no outstanding maintainer + `CHANGES_REQUESTED`; the `--maintainer-integration` path waives the second approval and is not + itself an approving review. +- Security review is a separate duty and this round triggers it: L3 owns + `src/server/management/oauth-account-routes.ts`, an authentication and credential surface, so + `MAINTAINERS.md` requires explicit security review before that lane lands. CI being green does not + discharge it. + diff --git a/devlog/_plan/260911_lane_dispatch_round/110_tracking_refresh.md b/devlog/_plan/260911_lane_dispatch_round/110_tracking_refresh.md new file mode 100644 index 0000000000..9919b15088 --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/110_tracking_refresh.md @@ -0,0 +1,52 @@ +# Tracking refresh + +Run this to regenerate the lane rows of `060_ledger.md`. Every field it prints comes from a command, +so the ledger cannot drift into narration the way it did before audit round 2 caught a stale head. + +```bash +set -uo pipefail +REPO=lidge-jun/opencodex +for b in codex/260911-l1-responses-core codex/260911-l2-catalog-provider codex/260911-l3-account-pool codex/260911-l4-service-cli codex/260911-l5-integrations-io codex/260911-l6-streaming-tools codex/260911-l7-docs; do + head=$(git ls-remote origin "refs/heads/$b" | cut -f1) + if [ -z "$head" ]; then echo "$b MISSING"; continue; fi + pr=$(gh pr list -R "$REPO" --head "$b" --state open --json number,headRefOid,reviewDecision,mergeStateStatus) + prtxt=$(printf '%s' "$pr" | jq -r 'if length==0 then "pr=none" else "pr=#\(.[0].number) prhead=\(.[0].headRefOid[0:9]) review=\(.[0].reviewDecision // "-") mergeState=\(.[0].mergeStateStatus // "-")" end') + runs=$(gh run list -R "$REPO" --commit "$head" --json workflowName,conclusion,status,databaseId,event) + ci=$(printf '%s' "$runs" | jq -r '[.[] | select(.event=="pull_request" or .event=="push")] | group_by(.workflowName) | map(max_by(.databaseId)) | if length==0 then "ci=no-run" else (map(select(.conclusion != "success")) | if length>0 then "ci=NOT-GREEN[" + (map(.workflowName + "=" + (if (.conclusion // "") == "" then .status else .conclusion end)) | join(";")) + "]" else "ci=green[" + ((. | length | tostring)) + " workflows]" end) end') + echo "$b head=${head:0:9} $prtxt $ci" +done +``` + +## Reading rules + +- `pr=none` means `gh pr list --head` returned `[]`. Use `list`, never `view`: `gh pr view ` + exits 1 when no pull request exists and would abort the loop. +- `ci=no-run` means no `pull_request` or `push` run exists for that exact head. It is **not** green. + Every lane reads that way right now, before any thread pushes. +- The verdict reduces to the newest run per `workflowName` by `databaseId`, and requires *every* one + of them to be `success`. It is not an allowlist: a red workflow nobody thought to name still fails + the lane. +- Only `pull_request` and `push` events count, so a later `workflow_dispatch` success cannot outrank a + failed run of the same workflow. +- `mergeState=UNKNOWN` is common and is not a mergeability verdict; it appeared on #4210, #4203, and + on #4220 after it merged. +- Which checks are skipped inside a green workflow is a check-run fact, readable only from + `gh pr checks `. #4220 showed `Cross-platform CI` and `React Doctor` as workflow-level `success` + with the product jobs skipped inside, which is correct for a documentation-only pull request and + must be recorded as such rather than as green product CI. + +## Snapshot — 2026-09-10T16:47Z + +```text +codex/260911-l1-responses-core head=29c342da7 pr=none ci=no-run +codex/260911-l2-catalog-provider head=b28f06ee0 pr=none ci=no-run +codex/260911-l3-account-pool head=6fd401636 pr=none ci=no-run +codex/260911-l4-service-cli head=f48cede91 pr=none ci=no-run +codex/260911-l5-integrations-io head=a7a92089c pr=none ci=no-run +codex/260911-l6-streaming-tools head=3760f81fd pr=none ci=no-run +codex/260911-l7-docs head=cd5dcd6a2 pr=none ci=no-run +``` + +Exit 0. Any lane pull request opened after that time supersedes this snapshot; re-run the block +rather than trusting it. + From f73fcc58ddb9018a5f7bb1070d28f7c71b4844fb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 02:04:14 +0900 Subject: [PATCH 024/126] docs(devlog): fold the seven-lane feasibility audit into lane ownership and scope --- .../260911_lane_dispatch_round/000_plan.md | 5 +- .../010_lane_partition.md | 19 ++++-- .../020_lane_packets.md | 41 +++++++++---- .../120_wp4_plan.md | 46 ++++++++++++++ .../130_wp4_feasibility.md | 60 +++++++++++++++++++ 5 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 devlog/_plan/260911_lane_dispatch_round/120_wp4_plan.md create mode 100644 devlog/_plan/260911_lane_dispatch_round/130_wp4_feasibility.md diff --git a/devlog/_plan/260911_lane_dispatch_round/000_plan.md b/devlog/_plan/260911_lane_dispatch_round/000_plan.md index aecb3b0a38..9b5ee7f38c 100644 --- a/devlog/_plan/260911_lane_dispatch_round/000_plan.md +++ b/devlog/_plan/260911_lane_dispatch_round/000_plan.md @@ -34,6 +34,10 @@ Excluded on purpose, with the decision that blocks each one: - #4198, #4179 — publishing an official container image changes a documented policy ("opencodex does not publish an official container image"). - #4173 — the atomic update design competes with #4185 and #4203 already in flight. +- #4204 — removed after the feasibility audit: binding the reasoning-effort clamp to the Desktop + runtime needs `codex/runtime.ts`, `catalog/bundled.ts`, and `catalog/sync.ts`, because the catalog + probes one selected runtime and no caller passes a consumer identity. Resolving a catalog per + consumer is a design decision. - Contributor feature PRs (#4183, #4100, #4111, #4193, #4033, #4042) — these need review, not reimplementation, and reimplementing them would discard the author's work. @@ -68,4 +72,3 @@ are repeated inside every packet so a lane thread that never reads this file sti Merges are serialized through the orchestrator because `dev` is protected and shared. A lane PR merges when its exact head is green on final-head CI; the landing is proven by fetching `origin/dev` and checking ancestry, never by the merge command's own output. - diff --git a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md index 8c942206ad..bd896c3f8d 100644 --- a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md +++ b/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md @@ -38,13 +38,14 @@ Collision-free PRs, touching no file any other open PR touches: #4062, #4104, #4 **L2** `codex/260911-l2-catalog-provider` — `src/providers/quota.ts`, `src/providers/quota-types.ts`, `src/providers/quota-wire.ts`, `src/providers/quota-routing-cache.ts`, `src/providers/quota-key-accounts.ts`, -`src/providers/account-quota-disk.ts`, `src/providers/registry.ts`. Stack: #4201. +`src/providers/account-quota-disk.ts`, `src/providers/registry.ts`, and the roster oracle +`tests/providers/provider-registry-parity.test.ts`. Stack: #4201. **L3** `codex/260911-l3-account-pool` — `src/codex/account-usability.ts`, `src/codex/account-pause.ts`, `src/codex/account-store.ts`, `src/codex/account-runtime-state.ts`, `src/codex/plan.ts`, `src/codex/plan-from-token.ts`, `src/codex/warmup.ts`, `src/codex/model-entitlements.ts`, `src/server/responses/codex-auth-error.ts`, -`src/server/management/oauth-account-routes.ts`, the single key `codexPool.excludedPlans` in +`src/codex/auth-api.ts`, `src/codex/routing.ts`, `src/types/config.ts`, the single key `codexPool.excludedPlans` in `src/config.ts`, and `docs-site/src/content/docs/guides/codex-integration.md` and its seven locale copies under `docs-site/src/content/docs/{fr,ja,ko,ru,tr,zh-cn,zh-tw}/guides/codex-integration.md`. Stack: #4126 → #4212 → #4211. @@ -53,10 +54,11 @@ Stack: #4126 → #4212 → #4211. `bin/ocx.mjs`, `src/cli.ts`, `src/service.ts`, `src/config/pending-teardown.ts`, `src/lib/bun-runtime.ts`, `src/lib/package-tree-integrity.ts`, `src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, `src/codex/cli-install-provenance.ts`, -`docs-site/src/content/docs/getting-started/installation.md`. Stack: #4202 → #4169 → #4204 → #4207. +`docs-site/src/content/docs/getting-started/installation.md`. Stack: #4202 → #4169 → #4207. -**L5** `codex/260911-l5-integrations-io` — directory `src/integrations/`; file -`src/config/atomic-write.ts`. Stack: #4197 → #4214. +**L5** `codex/260911-l5-integrations-io` — directory `src/integrations/`; files +`src/config/atomic-write.ts`, `src/clients/config-export.ts`, +`src/clients/config-export/contracts.ts`. Stack: #4197 → #4214. **L6** `codex/260911-l6-streaming-tools` — `src/server/responses/codex-ws-exchange.ts`, `src/server/responses/codex-ws-wire.ts`, directory `src/adapters/qoder/`. Stack: #4191 → #4190. @@ -73,3 +75,10 @@ Stack: #4126 → #4212 → #4211. - `src/config.ts`: only L3, and only `codexPool.excludedPlans`. - `docs-site/src/content/docs/guides/providers.md`: L7 only. A lane whose carried PR edits it drops that hunk and reports the wording to the orchestrator. + +## Amendments from the seven-lane feasibility audit + +Ownership above already carries them; `130_wp4_feasibility.md` records why each was granted. In short: +L2 gained the roster oracle it must update, L3 traded `oauth-account-routes.ts` for the Codex account +surface `auth-api.ts` plus `routing.ts` and `types/config.ts`, L5 gained the export-client contract, +and #4204 left the round because binding the clamp to the Desktop runtime is a design decision. diff --git a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md index 6c6de9e57a..08dae15218 100644 --- a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md +++ b/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md @@ -95,7 +95,10 @@ Owned: `src/server/responses/core.ts`, `src/server/responses/compact.ts`, Worktree `~/.codex/worktrees/260911-l2/opencodex`, branch `codex/260911-l2-catalog-provider`. Owned: `src/providers/quota.ts`, `quota-types.ts`, `quota-wire.ts`, `quota-routing-cache.ts`, -`quota-key-accounts.ts`, `account-quota-disk.ts`, `registry.ts` (all under `src/providers/`). +`quota-key-accounts.ts`, `account-quota-disk.ts`, `registry.ts` (all under `src/providers/`), plus +`tests/providers/provider-registry-parity.test.ts`, the oracle that locks the roster you are changing: +it asserts the two-model list at `:464` and that `glm-5.3-flash` is absent at `:508`, so the catalog +half cannot land without updating it. 1. **#4201 — BigModel Responses Coding Plan: missing quota probe and GLM-5.3-Flash catalog support** (reporter `bluesmilery`). **Decision: do not build on #4210.** It is an open draft by `Ingwannu` @@ -111,9 +114,9 @@ Owned: `src/providers/quota.ts`, `quota-types.ts`, `quota-wire.ts`, `quota-routi Worktree `~/.codex/worktrees/260911-l3/opencodex`, branch `codex/260911-l3-account-pool`. Owned: `src/codex/account-usability.ts`, `account-pause.ts`, `account-store.ts`, -`account-runtime-state.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, `model-entitlements.ts` -(all under `src/codex/`), plus `src/server/responses/codex-auth-error.ts`, -`src/server/management/oauth-account-routes.ts`, the single key `codexPool.excludedPlans` in +`account-runtime-state.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, `model-entitlements.ts`, `auth-api.ts`, `routing.ts` +(all under `src/codex/`), plus `src/server/responses/codex-auth-error.ts`, `src/types/config.ts`, +the single key `codexPool.excludedPlans` in `src/config.ts`, and `docs-site/src/content/docs/guides/codex-integration.md` and its seven locale copies under `docs-site/src/content/docs/{fr,ja,ko,ru,tr,zh-cn,zh-tw}/guides/codex-integration.md`. @@ -124,10 +127,17 @@ Owned: `src/codex/account-usability.ts`, `account-pause.ts`, `account-store.ts`, 2. **#4212 — an account stuck on a failed credential refresh silently drops its models.** The ask is attribution, not new routing. **Decision: this round covers the refusal string (`codex-auth-error.ts:35`), the account-health surface, and the management route - (`oauth-account-routes.ts`). It does not change the L1 refusal call sites at `core.ts:2243` and - `compact.ts:296`;** if review asks for those, open a follow-up issue rather than crossing into L1. + on the Codex account surface, which is `poolAccountDto` in `src/codex/auth-api.ts:377` under + `/api/codex-auth/accounts`. The feasibility audit found the earlier grant of + `oauth-account-routes.ts` was the wrong route: it serves the generic `/api/oauth/accounts` and + `src/oauth/index.ts:331` excludes ChatGPT from it. The reporter's 503 is inlined at + `responses/core.ts:2336` and `compact.ts:383`, which L1 owns, and the model-list drop is published + from `catalog/sync.ts:1777`. Both are out of scope: write `Refs #4212`, not `Closes`,** and record + them as follow-ups. 3. **#4211 — keep Free-tier accounts out of pool selection.** **Decision: ship** - **`codexPool.excludedPlans` as an array, absent by default,** so an existing install sees no + **`codexPool.excludedPlans` as an array, absent by default, filtered in `getEligiblePoolAccounts`** + **at `src/codex/routing.ts:1248` rather than in `isCodexAccountUsable`, which is where pause already + lives, and explicit namespace selection at `auth-context.ts:922` keeps working,** so an existing install sees no behaviour change. Do not ship `minimumPlan`: ranking plans needs an ordering this repository does not have. **Decision: this round ships selection only.** If the dashboard or CLI display the issue also asks for needs `src/cli/account.ts`, a GUI component, or a locale key, stop and report; write @@ -142,6 +152,11 @@ Owned: directories `src/update/`, `src/cli/`, `src/client/`; files `bin/ocx.mjs` `src/lib/package-tree-integrity.ts`, `src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, `src/codex/cli-install-provenance.ts`, `docs-site/src/content/docs/getting-started/installation.md`. +Your stack is #4202 → #4169 → #4207. **#4204 was removed from the round** by the feasibility audit: +binding the clamp to the Desktop runtime needs `codex/runtime.ts:573`, `catalog/bundled.ts:239`, and +`catalog/sync.ts:1945`, because the catalog probes one selected runtime and no caller passes a +consumer identity. Resolving a catalog per consumer is a design decision this round does not make. + 1. **#4202 — global pnpm installations cannot self-update.** Carry PR #4203 by `oliver-mee` (open **draft**, `CHANGES_REQUESTED`, 36 files). **Decision: the keep-set is exactly** `bin/ocx.mjs`, `src/cli.ts`, `src/cli/launcher-context.ts`, `src/config/pending-teardown.ts`, @@ -173,14 +188,20 @@ Owned: directories `src/update/`, `src/cli/`, `src/client/`; files `bin/ocx.mjs` Worktree `~/.codex/worktrees/260911-l5/opencodex`, branch `codex/260911-l5-integrations-io`. -Owned: directory `src/integrations/`; file `src/config/atomic-write.ts`. No open PR touches either. +Owned: directory `src/integrations/`; files `src/config/atomic-write.ts`, +`src/clients/config-export.ts`, `src/clients/config-export/contracts.ts`. Note `src/clients/` (plural) +is unrelated to L4's `src/client/` (singular). Open draft #3833 also edits the export-client surface; +report the overlap rather than merging the two lines of work. 1. **#4197 — the DSH integration's atomic replace changes file ownership and causes `EACCES` across UIDs.** **Decision: refuse the integration write when the target exists and its owner is not the process euid, with an explicit API error. Do not relax the `0600` hardening and do not attempt `fchown`.** A metadata-preserving replace can be proposed later as its own issue. -2. **#4214 — add Cline as a supported client integration.** Follow the existing registry pattern in - `src/integrations/registry.ts`. +2. **#4214 — add Cline as a supported client integration.** `IntegrationClientId` is an alias of + `ExportClientId` at `clients/config-export/contracts.ts:84`, and the writer needs `EXPORT_CLIENTS` + from `clients/config-export.ts:1112`, which is why both are yours. **Decision: ship the CLI and + registry path only. The dashboard tab needs a locale key this round forbids, so write `Refs #4214`** + and leave the tab as a follow-up. ## L6 — streaming and vendor tool leakage diff --git a/devlog/_plan/260911_lane_dispatch_round/120_wp4_plan.md b/devlog/_plan/260911_lane_dispatch_round/120_wp4_plan.md new file mode 100644 index 0000000000..8734861f2d --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/120_wp4_plan.md @@ -0,0 +1,46 @@ +# wp4 — merges, and the readiness check the lane merges need + +wp4 owns serialized merges on final-head green CI with fetched ancestry proof. Three merges have +already run under exactly that gate, all of them the round's own pull requests: + +| PR | Head merged | Landing proof | +|---|---|---| +| #4217 | `538668bb0` | `git merge-base --is-ancestor` against fetched `origin/dev` | +| #4220 | `6a3774c15` | same | +| #4221 | `9f550ae56` | same | + +No lane pull request exists yet, so the lane half of this phase has nothing to merge. That is an +external dependency: a thread has to be opened against each worktree, which an agent cannot do. + +## What this phase does before deferring the lane merges + +Every defect the three round audits found had the same shape: a lane was told to fix something whose +code does not live entirely inside the paths that lane owns. #4212 needed a management route L3 did +not have. #4207 was client code in the wrong lane. #4190's leak sat in a directory no lane owned. +Each was found by reading one issue against the tree. + +A lane that discovers this itself burns a cycle and stops, and the merge this phase waits for never +arrives. So before deferring, check all seven at once: one read-only `xai/grok-4.6` subagent per lane, +fresh context, asked a single question — can this lane's stack be implemented entirely inside its +owned paths, and if not, which exact path is missing? The questions are independent, so the seven run +concurrently. Their verdicts are this phase's audit. + +## Acceptance + +- The three completed merges are recorded with their exact heads and landing proofs. +- Seven verdicts, one per lane, each naming either that the stack fits the owned paths or the exact + unowned `path:line` it needs. +- Every finding is folded into that lane's packet, or recorded with a rebuttal the way the #4184 + finding was rebutted with the issue text that disproved it. +- Amended packets are pushed and their remote heads read back with `git ls-remote`. +- The lane merges are recorded as deferred with the reason, not as done. + +## Out of scope + +No implementation. No lane pull request. No merge of a lane branch, because none exists. A subagent +that proposes a fix has exceeded its packet; only the path gap is used. + +## Local checks + +`NOT RUN`. Every subagent is read-only. + diff --git a/devlog/_plan/260911_lane_dispatch_round/130_wp4_feasibility.md b/devlog/_plan/260911_lane_dispatch_round/130_wp4_feasibility.md new file mode 100644 index 0000000000..d72a6126d2 --- /dev/null +++ b/devlog/_plan/260911_lane_dispatch_round/130_wp4_feasibility.md @@ -0,0 +1,60 @@ +# wp4 — seven-lane feasibility audit + +Seven read-only `xai/grok-4.6` subagents, one per lane, dispatched in one round with fresh context. +Each was asked a single question: can this lane's stack be implemented entirely inside its owned +paths, and if not, which exact path is missing? Four came back `fits`, three came back `gaps`. + +Three of seven lanes would have started, hit an unowned file, and stopped. That is the cost this +phase removed. + +## Verdicts + +| Lane | Verdict | Substance | +|---|---|---| +| L1 | fits | #4172 lands at `opencode-go-transport.ts:32` with every fanout call site owned; #4176 at `responses-undeclared-tool-guard.ts:324` and `types/tools.ts:69`. | +| L2 | gaps | The quota half fits `quota.ts:2909`, but seeding `glm-5.3-flash` at `registry.ts:2651` fails `tests/providers/provider-registry-parity.test.ts:464` and `:508`, which lock the roster and assert Flash is absent. | +| L3 | gaps | #4126 fits `warmup.ts:31` and `:290`. #4212 was pointed at the wrong surface. #4211 was pointed at the wrong function. | +| L4 | gaps | #4202, #4169, and #4207 fit. #4204 cannot bind the clamp to the Desktop runtime from `effort.ts` alone. | +| L5 | gaps | #4197 fits `integrations/config-io.ts:259`. #4214 needs the export-client contract, which L5 does not own. | +| L6 | fits | #4191 at `codex-ws-exchange.ts:214`; #4190 by wrapping the `emit` callback inside `adapters/qoder/adapter.ts:58-67`. | +| L7 | fits | #4215 at `guides/providers.md:70`; #4200 at `guides/remote-hub.md:77`. | + +## Dispositions + +**L2 — #4201.** Grant `tests/providers/provider-registry-parity.test.ts`. A lane that changes a +registry roster owns the oracle that asserts the roster; otherwise the change cannot land at all. + +**L3 — #4212.** The packet granted `oauth-account-routes.ts`, which is the wrong route: it serves the +generic `/api/oauth/accounts`, and `oauth/index.ts:331` excludes ChatGPT from it. Codex pool accounts +are served by `poolAccountDto` in `src/codex/auth-api.ts:377` under `/api/codex-auth/accounts`, so +that file is granted instead. The reporter's 503 is inlined at `responses/core.ts:2336` and +`compact.ts:383`, which L1 owns, and the model-list drop is published from `catalog/sync.ts:1777`. +**Decision: #4212 is narrowed to per-account refresh-failure attribution on the Codex account +surface.** The 503 wording and the catalog-drop attribution are recorded as follow-ups, and the pull +request says `Refs #4212` rather than `Closes`. + +**L3 — #4211.** Auto-selection filters in `getEligiblePoolAccounts` at `codex/routing.ts:1248`, not in +`isCodexAccountUsable`, which is why pause does not appear in the latter either. Grant +`src/codex/routing.ts` and `src/types/config.ts`, since the field needs a type next to +`pausedCodexAccountIds` and the schema alone does not provide one. **Decision: the filter applies to +automatic selection only; explicit namespace selection at `auth-context.ts:922` keeps working,** which +is what the issue asks for. + +**L4 — #4204. Removed from the round.** Binding the clamp to the Desktop runtime requires +`codex/runtime.ts:573`, `catalog/bundled.ts:239`, and `catalog/sync.ts:1945`: the catalog probes one +selected runtime and no caller passes a consumer identity. Deciding that a catalog should be resolved +per consumer is a design decision, which is exactly what this round's decision-free filter excludes. +L4's stack becomes #4202 → #4169 → #4207. + +**L5 — #4214.** `IntegrationClientId` is an alias of `ExportClientId` from +`clients/config-export/contracts.ts:84`, and the writer needs `EXPORT_CLIENTS` from +`clients/config-export.ts:1112`. Grant both. The dashboard tab additionally needs a locale key, which +this round forbids. **Decision: ship the CLI and registry path; the dashboard tab is a follow-up, and +the pull request says `Refs #4214`.** Note that open draft #3833 also edits the export-client surface; +L5 reports the overlap rather than merging the two lines of work. + +## What this does not change + +No lane gained a path another lane owns. `src/clients/` (plural) is unrelated to L4's `src/client/` +(singular). The four `fits` lanes are untouched. + From 6db04ea74bef1d649a47ac4b1b89aca20f8da2ce Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:01 +0900 Subject: [PATCH 025/126] docs(devlog): seed the L1 dispatch packet --- .../260911_l1_responses_core/000_packet.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 devlog/_plan/260911_l1_responses_core/000_packet.md diff --git a/devlog/_plan/260911_l1_responses_core/000_packet.md b/devlog/_plan/260911_l1_responses_core/000_packet.md new file mode 100644 index 0000000000..fec95cdedb --- /dev/null +++ b/devlog/_plan/260911_l1_responses_core/000_packet.md @@ -0,0 +1,92 @@ +# Dispatch packet — L1 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L1 — Responses pipeline and tool contract + +Worktree `~/.codex/worktrees/260911-l1/opencodex`, branch `codex/260911-l1-responses-core`. + +Owned: `src/server/responses/core.ts`, `src/server/responses/compact.ts`, +`src/server/responses/policy-fallback.ts`, `src/server/chat-completions.ts`, +`src/server/claude-messages.ts`, `src/server/request-log-conversation.ts`, +`src/server/responses-undeclared-tool-guard.ts`, `src/providers/opencode-go-transport.ts`, +`src/types/tools.ts`, and `docs-site/src/content/docs/reference/configuration/providers.md` (the page +#4184 already edits). You do not own `codex-ws-exchange.ts` or `codex-ws-wire.ts` (L6) or +`codex-auth-error.ts` (L3). + +1. **#4172 — OpenCode Go sessionless requests omit `x-opencode-session`.** Expected behaviour is + fixed by the issue: every request to the canonical Go destination carries the header; identity + keeps its stable per-conversation value; no identity gets an isolated per-request value rather + than none and rather than one shared global id; an explicit header still wins. Carry PR #4184 by + `chilung-cgu` (open, not a draft, `CHANGES_REQUESTED`); read the review first. Most urgent item in + the round: upstream ended the grace period on 09/06 and now errors without the header. +2. **#4176 — a routed provider prefixes a bare Codex tool with `default.`.** **Decision: normalize + the invented prefix back at the undeclared-tool guard** — the #4181 shape, which is what the issue + states. #4181 by `chilung-cgu` is open, not a draft, `CHANGES_REQUESTED`; #4171 by `rrmlima` is an + open **draft** at `CHANGES_REQUESTED` and its unified-exec rewrite is out of round scope. Credit + `rrmlima` only if you reuse code from #4171. + +`core.ts` is contended by four open PRs. Keep the diff minimal; do not reformat around it. + From 9e75542ffd7052324bf6d296958e5ff50c1705ac Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 06:39:23 +0900 Subject: [PATCH 026/126] fix(opencode-go): give sessionless requests an isolated session lane OpenCode Go began rejecting requests without x-opencode-session on 2026-09-06. Requests carrying conversation identity already got a stable lane from #3945; requests with none left the transport unheaderised and hit 400. The lane is now allocated once per admitted Request and carried across every boundary that rebuilds one, including the policy fallback retry, so a hop to the next candidate does not split one turn into two Go conversations. Co-authored-by: chilung-cgu <215337896+chilung-cgu@users.noreply.github.com> --- .../docs/reference/configuration/providers.md | 21 ++++ src/providers/opencode-go-transport.ts | 10 +- src/server/chat-completions.ts | 9 +- src/server/claude-messages.ts | 8 +- src/server/request-log-conversation.ts | 42 ++++++- src/server/responses/compact.ts | 3 +- src/server/responses/core.ts | 6 +- src/server/responses/policy-fallback.ts | 8 +- .../opencode-go-session-header.test.ts | 106 +++++++++++++++++- 9 files changed, 199 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 5f3fc99649..d40de409d2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -949,6 +949,27 @@ receive the default only when the setting is absent; custom renamed entries keep value and do not acquire this default by destination matching. Chat model routes keep their existing protocol. The stateless flag does not force Responses streaming into JSON. +## OpenCode Go session affinity + +Every request opencodex routes to an OpenCode Go destination carries an `x-opencode-session` header. +The upstream began rejecting requests without it on 2026-09-06, so the header is not an optimization. + +The value depends on what the request already knows about itself: + +- An operator-configured `x-opencode-session` on the provider is preserved exactly as written. +- A request that carries conversation identity — Codex thread headers, a Claude `metadata.user_id`, + a `session_id`, or an inbound `x-opencode-session` — is hashed into a stable per-conversation value, + so every turn of one conversation reaches Go under the same session. +- A request with no identity at all, such as a model-availability probe or a first request before any + conversation metadata exists, receives a value allocated once for that request. It is isolated from + other requests rather than shared, and it survives the places opencodex rebuilds the request: the + translation to the internal Responses shape, compaction, combo children, and the policy-fallback + retry that hands the turn to the next candidate. + +Non-Go destinations are unaffected: opencodex never derives or adds the session header for them. A +header an operator configured on such a provider is still sent, because opencodex leaves that +configuration alone. + ## OpenCode Go reasoning efforts Go catalog rows preserve their configured reasoning efforts exactly, including during diff --git a/src/providers/opencode-go-transport.ts b/src/providers/opencode-go-transport.ts index a863d24f37..e24d1d9cbd 100644 --- a/src/providers/opencode-go-transport.ts +++ b/src/providers/opencode-go-transport.ts @@ -22,7 +22,15 @@ export function deriveOpenCodeGoSessionId(sessionLane: string): string { return `ocx_${digest}`; } -/** Add per-conversation Go affinity only to the canonical fixed-key destination. */ +/** + * Add Go affinity only to the canonical fixed-key destination. + * + * Callers on the request path resolve the lane with `getOrAllocateRequestSessionLane`, which returns + * real conversation identity when the client supplied it and a per-request value otherwise, so a + * request reaching this helper from the proxy always carries a lane. The `!sessionLane` guard stays + * for direct callers that have no request context; it is not a per-request identity of its own, and + * minting one here would hand each retry a different value. + */ export function resolveOpenCodeGoTransport( provider: T, sessionLane: string | undefined, diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 20c29856ec..44409a660c 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -26,7 +26,10 @@ import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport"; -import { normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation"; +import { + getOrAllocateRequestSessionLane, + linkRequestSessionLane, +} from "./request-log-conversation"; import type { OcxConfig } from "../types"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { @@ -142,8 +145,7 @@ async function handleChatCompletionsWithBudget( let chatNativeRoute: ReturnType | null = null; try { const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody)); - route.provider = resolveOpenCodeGoTransport(route.provider, - sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))); + route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); // Settle the wire once so every branch below reads the adapter this model will // actually use, not the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat"); @@ -305,6 +307,7 @@ async function handleChatCompletionsWithBudget( headers, body: internalBodyJson, }); + linkRequestSessionLane(req, internalReq); let nativeLogged = false; const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" | "non_stream" }) => { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index a16466459b..ba4d4999d3 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -36,7 +36,12 @@ import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; -import { conversationIdFromClaudeMetadata, normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation"; +import { + conversationIdFromClaudeMetadata, + linkRequestSessionLane, + normalizeLogConversationId, + sessionLaneIdFromRequest, +} from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; import { @@ -897,6 +902,7 @@ async function handleClaudeMessagesWithBudget( headers, body: JSON.stringify(internalBody), }); + linkRequestSessionLane(req, internalReq); } finally { reservation.release(); } diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index ed1e2f33d4..fd77f05798 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -2,7 +2,7 @@ * Best-effort chat/session correlation for Logs / usage.jsonl (#330). * Opaque ids only — never persist raw emails or Claude Desktop system-hash fallbacks. */ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; /** Reject absurdly long client strings before hashing (DoS / JSONL bloat). */ export const LOG_CONVERSATION_ID_INPUT_MAX = 4096; @@ -217,3 +217,43 @@ export function summarizeConversationLogs(entries: readonly TotalsSource[]): Con unmeteredRequests, }; } + +/** + * Request-scoped Go affinity for requests that carry no conversation identity. + * + * A sessionless request must still reach OpenCode Go with `x-opencode-session`, because the upstream + * began rejecting requests without it on 2026-09-06. It must not reuse one global value either, which + * would smear unrelated probes into a single conversation. So the lane is allocated once per admitted + * `Request` object and retained for that object's lifetime. + * + * The identity has to survive every place the proxy rebuilds a `Request`: translation to the internal + * Responses shape, compaction, and — the boundary that matters most — the policy fallback retry, where + * a second candidate would otherwise be handed a freshly minted lane after a retryable failure. + * `linkRequestSessionLane` carries the allocation across those boundaries. + */ +const requestAllocatedSessionLanes = new WeakMap(); + +/** + * Resolve the session lane for a request: real conversation identity when the client supplied it, + * otherwise a per-request value allocated once and reused for retries on the same object. + */ +export function getOrAllocateRequestSessionLane(req: Request): string { + const explicit = sessionLaneIdFromRequest(req.headers) + ?? normalizeLogConversationId(req.headers.get("x-opencode-session")); + if (explicit) return explicit; + + const existing = requestAllocatedSessionLanes.get(req); + if (existing) return existing; + const allocated = randomUUID(); + requestAllocatedSessionLanes.set(req, allocated); + return allocated; +} + +/** + * Carry a source request's lane onto a request the proxy built from it, so a rebuilt request keeps + * the conversation it belongs to instead of looking sessionless again. + */ +export function linkRequestSessionLane(sourceReq: Request, targetReq: Request): void { + requestAllocatedSessionLanes.set(targetReq, getOrAllocateRequestSessionLane(sourceReq)); +} + diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 3c345a9f1d..8add02babf 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -157,7 +157,7 @@ import { } from "./core"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers"; import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; -import { sessionLaneIdFromRequest } from "../request-log-conversation"; +import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation"; import { recallComboForLane } from "./combo-session-recall"; export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024; @@ -1149,6 +1149,7 @@ export async function handleResponsesCompact( headers: internalHeaders, body: JSON.stringify(internalBody), }); + linkRequestSessionLane(req, internalReq); const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) }); if (!response.ok) return response; let json: { output?: unknown[]; status?: unknown; error?: unknown }; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c1ce136ca4..425621814c 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -320,6 +320,8 @@ import { } from "../request-log"; import { conversationIdFromResponsesRequest, + getOrAllocateRequestSessionLane, + linkRequestSessionLane, normalizeLogConversationId, reasoningReplayConversationIdFromResponsesRequest, sessionLaneIdFromRequest, @@ -2451,8 +2453,7 @@ async function applyFinalRouteRequestNormalization(args: { // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter // this request will actually use (#404). - route.provider = resolveOpenCodeGoTransport(route.provider, - sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))); + route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; @@ -2816,6 +2817,7 @@ export async function handleComboResponses( headers: childHeaders, body: JSON.stringify(childBody), }); + linkRequestSessionLane(req, childRequest); let resolvedAuth: CodexAuthContext | undefined; let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; const started = Date.now(); diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 13c90bdcee..e3b061ed28 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -2,6 +2,7 @@ import { comboFailureDecision } from "../../combos/failover"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress"; import { finishRequestAttempt, type RequestLogContext } from "../request-log"; +import { linkRequestSessionLane } from "../request-log-conversation"; import type { OcxConfig } from "../../types"; import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace"; import { handleResponses as handleResponsesCore } from "./core"; @@ -56,12 +57,17 @@ function requestWithCandidate( headers.delete("content-encoding"); headers.delete("content-length"); headers.set("content-type", "application/json"); - return new Request(req.url, { + const retryRequest = new Request(req.url, { method: req.method, headers, body: JSON.stringify({ ...rawBody, model: `${candidate.provider}/${candidate.model}` }), signal: req.signal, }); + // A sessionless request keeps the lane it was already allocated. Without this the second + // candidate reaches OpenCode Go under a different x-opencode-session than the first attempt, + // which is the same conversation split the header exists to prevent. + linkRequestSessionLane(req, retryRequest); + return retryRequest; } function errorCodeFromText(text: string): string | undefined { diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index c176dc703e..9b326e294c 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -3,6 +3,8 @@ import { providerConfigSeed } from "../../src/providers/derive"; import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-transport"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import { handleResponses } from "../../src/server/responses/core"; +import { handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates } from "../../src/server/responses/policy-fallback"; +import { getOrAllocateRequestSessionLane } from "../../src/server/request-log-conversation"; import { handleChatCompletions } from "../../src/server/chat-completions"; import { handleClaudeMessages } from "../../src/server/claude-messages"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -133,12 +135,17 @@ describe("OpenCode Go session affinity (#3344)", () => { expect(first.headers.get(SESSION_HEADER)).not.toContain("conversation-a"); }); - test("Claude recognizes renamed canonical Go destinations and omits shared system affinity", async () => { + test("Claude recognizes renamed canonical Go destinations and isolates a request with no identity", async () => { const input = { claude: true, model: CHAT_MODEL, providerName: "renamed-go" }; const metadata = await captureRequest({ ...input, metadataUserId: "user_test_account__session_conversation-a" }); const desktop = await captureRequest(input); + const secondDesktop = await captureRequest(input); expect(metadata.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); - expect(desktop.headers.has(SESSION_HEADER)).toBe(false); + // A shared system prompt is not identity, so this request has none. It still has to carry the + // header — Go rejects requests without one — but under a lane of its own rather than a shared value. + expect(desktop.headers.get(SESSION_HEADER)).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(desktop.headers.get(SESSION_HEADER)).not.toBe(metadata.headers.get(SESSION_HEADER)); + expect(secondDesktop.headers.get(SESSION_HEADER)).not.toBe(desktop.headers.get(SESSION_HEADER)); }); test("Claude explicit Go header precedes metadata and matches native Chat affinity", async () => { @@ -185,11 +192,17 @@ describe("OpenCode Go session affinity (#3344)", () => { } }); - test(`Claude ${model} omits Go affinity without usable metadata identity`, async () => { + test(`Claude ${model} isolates each request whose metadata identity is unusable`, async () => { + const seen = new Set(); for (const metadataUserId of [undefined, "", " \t\n ", "invalid\u0000identity", "x".repeat(4097)]) { const captured = await captureRequest({ claude: true, model, metadataUserId }); expect(captured.url).toBe(url); - expect(captured.headers.has(SESSION_HEADER)).toBe(false); + // Unusable identity is not the same as no header: the request still reaches Go, and it does + // so under a lane nobody else shares. + const lane = captured.headers.get(SESSION_HEADER); + expect(lane).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(seen.has(lane!)).toBe(false); + seen.add(lane!); expect(captured.headers.has("session_id")).toBe(false); } }); @@ -367,3 +380,88 @@ describe("OpenCode Go session affinity (#3344)", () => { expect(captured.headers.has(SESSION_HEADER)).toBe(false); }); }); + +describe("OpenCode Go affinity across the policy fallback retry (#4172)", () => { + const policyTrace = { + version: 1, + decisionId: "decision-policy-go", + createdAt: Date.now(), + requestedModel: "policy/go", + routeKind: "policy", + profile: { id: "profile-go", revision: "rev-1" }, + requirements: [], + candidates: [ + { provider: "opencode-go", model: MUSE_MODEL, eligible: true, exclusions: [], score: { total: 2 } }, + { provider: "opencode-go-2", model: MUSE_MODEL, eligible: true, exclusions: [], score: { total: 1 } }, + ], + selected: { candidateIndex: 0, provider: "opencode-go", model: MUSE_MODEL, reason: "policy-test" }, + } as unknown as Parameters[0]; + + function sessionlessRequest(): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "policy/go", input: "ping", stream: false }), + }); + } + + function laneHeaderFor(req: Request): string | undefined { + return resolveOpenCodeGoTransport(opencodeGo(), getOrAllocateRequestSessionLane(req)) + .headers?.[SESSION_HEADER]; + } + + async function runPolicyFallback(req: Request): Promise { + const seen: Request[] = []; + let attempts = 0; + const runCore = (async (coreReq: Request, _config: unknown, logCtx: { routeDecision?: unknown }) => { + seen.push(coreReq); + logCtx.routeDecision = policyTrace; + attempts += 1; + if (attempts === 1) { + return new Response(JSON.stringify({ error: { message: "upstream temporarily unavailable" } }), { + status: 503, + headers: { "content-type": "application/json" }, + }); + } + return Response.json({ id: "resp_policy_go", object: "response", status: "completed", output: [] }); + }) as unknown as NonNullable[4]>["runCore"]; + + const config = { providers: { "opencode-go": opencodeGo() } } as unknown as OcxConfig; + const response = await handleResponsesWithPolicyFallback( + req, config, { model: "", provider: "" } as never, {}, { runCore }, + ); + expect(response.status).toBe(200); + return seen; + } + + test("a sessionless request keeps one lane when the policy hops to the next candidate", async () => { + const seen = await runPolicyFallback(sessionlessRequest()); + // The retry is a different Request object built by requestWithCandidate. Without the link it + // would look sessionless again and be handed a second lane, splitting one turn across two Go + // conversations — which is exactly what the header exists to prevent. + expect(seen).toHaveLength(2); + expect(seen[1]).not.toBe(seen[0]); + const first = laneHeaderFor(seen[0]!); + expect(first).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(laneHeaderFor(seen[1]!)).toBe(first); + }); + + test("two independent sessionless requests do not share a lane through the same fallback", async () => { + const firstTurn = await runPolicyFallback(sessionlessRequest()); + const secondTurn = await runPolicyFallback(sessionlessRequest()); + expect(laneHeaderFor(secondTurn[0]!)).not.toBe(laneHeaderFor(firstTurn[0]!)); + expect(laneHeaderFor(secondTurn[1]!)).toBe(laneHeaderFor(secondTurn[0]!)); + }); + + test("real conversation identity still wins over the per-request allocation", async () => { + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: codexHeaders("child-thread-policy"), + body: JSON.stringify({ model: "policy/go", input: "ping", stream: false }), + }); + const seen = await runPolicyFallback(req); + const expected = laneHeaderFor(req); + expect(laneHeaderFor(seen[0]!)).toBe(expected); + expect(laneHeaderFor(seen[1]!)).toBe(expected); + }); +}); From 7b249fb1f861176f66fee05bd9ead5479c74cf36 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:03 +0900 Subject: [PATCH 027/126] docs(devlog): seed the L5 dispatch packet --- .../260911_l5_integrations_io/000_packet.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 devlog/_plan/260911_l5_integrations_io/000_packet.md diff --git a/devlog/_plan/260911_l5_integrations_io/000_packet.md b/devlog/_plan/260911_l5_integrations_io/000_packet.md new file mode 100644 index 0000000000..24f0fcd6ec --- /dev/null +++ b/devlog/_plan/260911_l5_integrations_io/000_packet.md @@ -0,0 +1,85 @@ +# Dispatch packet — L5 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L5 — file IO and client integrations + +Worktree `~/.codex/worktrees/260911-l5/opencodex`, branch `codex/260911-l5-integrations-io`. + +Owned: directory `src/integrations/`; files `src/config/atomic-write.ts`, +`src/clients/config-export.ts`, `src/clients/config-export/contracts.ts`. Note `src/clients/` (plural) +is unrelated to L4's `src/client/` (singular). Open draft #3833 also edits the export-client surface; +report the overlap rather than merging the two lines of work. + +1. **#4197 — the DSH integration's atomic replace changes file ownership and causes `EACCES` across + UIDs.** **Decision: refuse the integration write when the target exists and its owner is not the + process euid, with an explicit API error. Do not relax the `0600` hardening and do not attempt + `fchown`.** A metadata-preserving replace can be proposed later as its own issue. +2. **#4214 — add Cline as a supported client integration.** `IntegrationClientId` is an alias of + `ExportClientId` at `clients/config-export/contracts.ts:84`, and the writer needs `EXPORT_CLIENTS` + from `clients/config-export.ts:1112`, which is why both are yours. **Decision: ship the CLI and + registry path only. The dashboard tab needs a locale key this round forbids, so write `Refs #4214`** + and leave the tab as a follow-up. + From 6b033f1f1a2079b02e020c2478775669c3dc5be6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 06:41:19 +0900 Subject: [PATCH 028/126] fix(integrations): refuse an integration write that would take the file from its owner The integration writer replaces a client's config with an atomic temp-file rename. The surviving inode belongs to whoever runs opencodex, so on a shared mount the replace silently dispossesses the owning product: DSH at uid 987 loses its own settings.yaml to opencodex at uid 1000 and dies with EACCES, while the restore call reports success. Preserving the previous uid needs a chown capability we usually lack, and relaxing the 0600 hardening would weaken every integration to fix one. So the write refuses and names both uids and the path. --- src/integrations/config-io.ts | 55 ++++++++++++++++++++++- tests/clients/integrations-writer.test.ts | 47 ++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts index 9cb5f97baa..dd61fcf351 100644 --- a/src/integrations/config-io.ts +++ b/src/integrations/config-io.ts @@ -256,7 +256,10 @@ export function fileIO(): Omit atomicWriteFile(path, text), + writeText: (path, text) => { + assertIntegrationWriteOwnership(path); + atomicWriteFile(path, text); + }, removeFile: path => rmSync(path, { force: true }), mkdirp: path => mkdirSync(path, { recursive: true, mode: 0o700 }), now: () => Date.now(), @@ -282,3 +285,53 @@ export function defaultIntegrationIO(store: { dropRecord: clientId => store.dropRecord(clientId), }; } + +/** + * Refuse to replace an integration file this process does not own. + * + * `atomicWriteFile` writes a private temp file and renames it over the target. That is the right + * shape for a secret — the replacement is atomic and the result is owner-only `0600` — but it also + * means the surviving inode belongs to whoever runs opencodex. When the target is another product's + * configuration on a shared mount, the replace quietly takes the file away from its owner. #4197 is + * that case: opencodex at uid 1000 replaces a DSH `settings.yaml` owned by uid 987, and DSH dies with + * `EACCES` on its next read while the restore call reports success. + * + * Preserving the previous uid would need a `chown` capability we usually do not have, and relaxing + * `0600` would weaken every integration to fix one. So refuse before writing, and say what is wrong, + * rather than succeeding into a broken state. + * + * Windows has no uid model here; `hardenSecretPath` owns that platform, so the check is skipped when + * the runtime exposes no effective uid. + */ +export function assertIntegrationWriteOwnership( + path: string, + deps: { + effectiveUid?: () => number | undefined; + ownerUid?: (target: string) => number | undefined; + } = {}, +): void { + const effectiveUid = deps.effectiveUid ?? (() => + typeof process.geteuid === "function" ? process.geteuid() : undefined); + const euid = effectiveUid(); + if (euid === undefined) return; + + const ownerUid = deps.ownerUid ?? ((target: string) => { + try { + return statSync(target).uid; + } catch { + // An absent or unreadable target has no owner to dispossess; the write itself will report + // any real failure. + return undefined; + } + }); + const owner = ownerUid(path); + if (owner === undefined || owner === euid) return; + + throw new Error( + `refusing to replace ${path}: it belongs to uid ${owner} while opencodex runs as uid ${euid}. ` + + "An atomic replace would transfer ownership of that file and leave its owner unable to read " + + "its own configuration. Run both under the same user, or give each one its own copy instead " + + "of sharing the mount.", + ); +} + diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index f2f69f4267..c80c28481d 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { buildClientContribution, type ExportModel } from "../../src/clients/config-export"; -import { fileIO, type IntegrationIO } from "../../src/integrations/config-io"; +import { assertIntegrationWriteOwnership, fileIO, type IntegrationIO } from "../../src/integrations/config-io"; import { canonicalContribution, fingerprint } from "../../src/integrations/ownership"; import { protectedContributionFingerprint } from "../../src/integrations/ownership-policy"; import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; @@ -1408,3 +1408,48 @@ describe("overwriting a conflict on purpose", () => { expect(store.readRecords().opencode!.fragmentPaths).not.toContainEqual(["providers", "opencodex-legacy"]); }); }); + +describe("integration write ownership guard (#4197)", () => { + const target = "/srv/dsh-data/settings.yaml"; + + test("refuses to replace a file owned by another uid", () => { + expect(() => assertIntegrationWriteOwnership(target, { + effectiveUid: () => 1000, + ownerUid: () => 987, + })).toThrow(/belongs to uid 987 while opencodex runs as uid 1000/); + }); + + test("names the path and both uids so the operator can act on it", () => { + let message = ""; + try { + assertIntegrationWriteOwnership(target, { effectiveUid: () => 1000, ownerUid: () => 987 }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain(target); + expect(message).toContain("transfer ownership"); + }); + + test("allows a file this process already owns", () => { + expect(() => assertIntegrationWriteOwnership(target, { + effectiveUid: () => 1000, + ownerUid: () => 1000, + })).not.toThrow(); + }); + + test("allows an absent target, which has no owner to dispossess", () => { + expect(() => assertIntegrationWriteOwnership(target, { + effectiveUid: () => 1000, + ownerUid: () => undefined, + })).not.toThrow(); + }); + + test("skips the check where the runtime exposes no effective uid", () => { + // Windows reaches the write through hardenSecretPath instead; a uid comparison there would be + // a guess, and a guess that refuses is worse than no guard. + expect(() => assertIntegrationWriteOwnership(target, { + effectiveUid: () => undefined, + ownerUid: () => 987, + })).not.toThrow(); + }); +}); From aa788905b1a2e45d0e1771f9d1c34594e0a32eb8 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:04 +0900 Subject: [PATCH 029/126] docs(devlog): seed the L7 dispatch packet --- devlog/_plan/260911_l7_docs/000_packet.md | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 devlog/_plan/260911_l7_docs/000_packet.md diff --git a/devlog/_plan/260911_l7_docs/000_packet.md b/devlog/_plan/260911_l7_docs/000_packet.md new file mode 100644 index 0000000000..a7e38e9f18 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/000_packet.md @@ -0,0 +1,81 @@ +# Dispatch packet — L7 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L7 — documentation + +Worktree `~/.codex/worktrees/260911-l7/opencodex`, branch `codex/260911-l7-docs`. + +Owned: `docs-site/src/content/docs/guides/providers.md`, +`docs-site/src/content/docs/guides/remote-hub.md`. You are the only lane that may edit +`providers.md`; L2 will send you wording rather than editing it. + +1. **#4215 — state whether each provider login consumes a subscription allowance or bills per + token.** Write the rule per authentication mode, then one explicit line per provider supporting + both. +2. **#4200 — the remote hub guide breaks on a fresh config** (nested `ocx config set` fails when the + parent object is absent) and has no macOS data-plane TLS example. English source first; + translations are a follow-up. + From 0f0e226be2274dee669dd9a8d469a13722ae3eeb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:02 +0900 Subject: [PATCH 030/126] docs(devlog): seed the L3 dispatch packet --- .../260911_l3_account_pool/000_packet.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 devlog/_plan/260911_l3_account_pool/000_packet.md diff --git a/devlog/_plan/260911_l3_account_pool/000_packet.md b/devlog/_plan/260911_l3_account_pool/000_packet.md new file mode 100644 index 0000000000..94123e6fe8 --- /dev/null +++ b/devlog/_plan/260911_l3_account_pool/000_packet.md @@ -0,0 +1,100 @@ +# Dispatch packet — L3 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L3 — Codex account pool + +Worktree `~/.codex/worktrees/260911-l3/opencodex`, branch `codex/260911-l3-account-pool`. + +Owned: `src/codex/account-usability.ts`, `account-pause.ts`, `account-store.ts`, +`account-runtime-state.ts`, `plan.ts`, `plan-from-token.ts`, `warmup.ts`, `model-entitlements.ts`, `auth-api.ts`, `routing.ts` +(all under `src/codex/`), plus `src/server/responses/codex-auth-error.ts`, `src/types/config.ts`, +the single key `codexPool.excludedPlans` in +`src/config.ts`, and `docs-site/src/content/docs/guides/codex-integration.md` and its seven locale copies under +`docs-site/src/content/docs/{fr,ja,ko,ru,tr,zh-cn,zh-tw}/guides/codex-integration.md`. + +1. **#4126 — a newly created ChatGPT Free account fails Codex warmup with HTTP 404.** Carry PR #4188 + by `chilung-cgu` (open **draft**, `REVIEW_REQUIRED`, reset by the readiness gate rather than + rejected). It carries `src/codex/warmup.ts`, its test, and eight `codex-integration.md` pages — + all of which you own. +2. **#4212 — an account stuck on a failed credential refresh silently drops its models.** The ask is + attribution, not new routing. **Decision: this round covers the refusal string + (`codex-auth-error.ts:35`), the account-health surface, and the management route + on the Codex account surface, which is `poolAccountDto` in `src/codex/auth-api.ts:377` under + `/api/codex-auth/accounts`. The feasibility audit found the earlier grant of + `oauth-account-routes.ts` was the wrong route: it serves the generic `/api/oauth/accounts` and + `src/oauth/index.ts:331` excludes ChatGPT from it. The reporter's 503 is inlined at + `responses/core.ts:2336` and `compact.ts:383`, which L1 owns, and the model-list drop is published + from `catalog/sync.ts:1777`. Both are out of scope: write `Refs #4212`, not `Closes`,** and record + them as follow-ups. +3. **#4211 — keep Free-tier accounts out of pool selection.** **Decision: ship** + **`codexPool.excludedPlans` as an array, absent by default, filtered in `getEligiblePoolAccounts`** + **at `src/codex/routing.ts:1248` rather than in `isCodexAccountUsable`, which is where pause already + lives, and explicit namespace selection at `auth-context.ts:922` keeps working,** so an existing install sees no + behaviour change. Do not ship `minimumPlan`: ranking plans needs an ordering this repository does + not have. **Decision: this round ships selection only.** If the dashboard or CLI display the issue + also asks for needs `src/cli/account.ts`, a GUI component, or a locale key, stop and report; write + `Refs #4211` rather than `Closes #4211` when the display half is not included. + From 64cc0adbfc39ab9eccaf047de9ccddde2884fb3b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:01 +0900 Subject: [PATCH 031/126] docs(devlog): seed the L2 dispatch packet --- .../260911_l2_catalog_provider/000_packet.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 devlog/_plan/260911_l2_catalog_provider/000_packet.md diff --git a/devlog/_plan/260911_l2_catalog_provider/000_packet.md b/devlog/_plan/260911_l2_catalog_provider/000_packet.md new file mode 100644 index 0000000000..529a5f5e33 --- /dev/null +++ b/devlog/_plan/260911_l2_catalog_provider/000_packet.md @@ -0,0 +1,85 @@ +# Dispatch packet — L2 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L2 — provider quota and registry + +Worktree `~/.codex/worktrees/260911-l2/opencodex`, branch `codex/260911-l2-catalog-provider`. + +Owned: `src/providers/quota.ts`, `quota-types.ts`, `quota-wire.ts`, `quota-routing-cache.ts`, +`quota-key-accounts.ts`, `account-quota-disk.ts`, `registry.ts` (all under `src/providers/`), plus +`tests/providers/provider-registry-parity.test.ts`, the oracle that locks the roster you are changing: +it asserts the two-model list at `:464` and that `glm-5.3-flash` is absent at `:508`, so the catalog +half cannot land without updating it. + +1. **#4201 — BigModel Responses Coding Plan: missing quota probe and GLM-5.3-Flash catalog support** + (reporter `bluesmilery`). **Decision: do not build on #4210.** It is an open draft by `Ingwannu` + at `REVIEW_REQUIRED` and it also edits `docs-site/src/content/docs/guides/providers.md`, which + belongs to L7. Implement #4201 independently; if your diff would overlap #4210's `quota.ts` hunks, + report that overlap to the orchestrator instead of merging the two lines of work. If the fix needs + documentation, write the wording in your report and let L7 land it. + +`quota.ts` is contended by four open PRs; keep the change surgical. + From 1bce6fac9dcc599cdd52fda0cb979cceb224fd54 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:04 +0900 Subject: [PATCH 032/126] docs(devlog): seed the L6 dispatch packet --- .../260911_l6_streaming_tools/000_packet.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 devlog/_plan/260911_l6_streaming_tools/000_packet.md diff --git a/devlog/_plan/260911_l6_streaming_tools/000_packet.md b/devlog/_plan/260911_l6_streaming_tools/000_packet.md new file mode 100644 index 0000000000..45a0a023b4 --- /dev/null +++ b/devlog/_plan/260911_l6_streaming_tools/000_packet.md @@ -0,0 +1,83 @@ +# Dispatch packet — L6 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L6 — streaming and vendor tool leakage + +Worktree `~/.codex/worktrees/260911-l6/opencodex`, branch `codex/260911-l6-streaming-tools`. + +Owned: `src/server/responses/codex-ws-exchange.ts`, `src/server/responses/codex-ws-wire.ts`, +directory `src/adapters/qoder/`. + +1. **#4191 — a long Codex thread fails only through the proxy** (WS 1006 / prelude timeout) while the + bypass works immediately. The timeout is `codex-ws-exchange.ts:214`. Reproduce first: establish + what length and timing trigger it and where the prelude budget goes. **Decision: the only in-scope + fix is to classify and report the timeout honestly, including the close code and the cause.** A + configurable budget, an SSE fallback, or a size preflight comes back as a report, not a patch. +2. **#4190 — vendor CLI agent scaffolding leaks into routed output** for `qoder`. **Decision: fix it + inside `src/adapters/qoder/` by sanitizing the vendor scaffolding out of routed output, failing + closed when the shape is unrecognized.** If the fix needs `src/adapters/coding-agent/protocol.ts`, + which no lane owns, stop and report. + From 48633161e2d5edc73f634a6494ca8d937e3370d4 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:02 +0900 Subject: [PATCH 033/126] docs(devlog): seed the L4 dispatch packet --- .../_plan/260911_l4_service_cli/000_packet.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 devlog/_plan/260911_l4_service_cli/000_packet.md diff --git a/devlog/_plan/260911_l4_service_cli/000_packet.md b/devlog/_plan/260911_l4_service_cli/000_packet.md new file mode 100644 index 0000000000..86dc8351a2 --- /dev/null +++ b/devlog/_plan/260911_l4_service_cli/000_packet.md @@ -0,0 +1,107 @@ +# Dispatch packet — L4 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L4 — service, update, CLI, and connected client + +Worktree `~/.codex/worktrees/260911-l4/opencodex`, branch `codex/260911-l4-service-cli`. + +Owned: directories `src/update/`, `src/cli/`, `src/client/`; files `bin/ocx.mjs`, `src/cli.ts`, +`src/service.ts`, `src/config/pending-teardown.ts`, `src/lib/bun-runtime.ts`, +`src/lib/package-tree-integrity.ts`, `src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, +`src/codex/cli-install-provenance.ts`, `docs-site/src/content/docs/getting-started/installation.md`. + +Your stack is #4202 → #4169 → #4207. **#4204 was removed from the round** by the feasibility audit: +binding the clamp to the Desktop runtime needs `codex/runtime.ts:573`, `catalog/bundled.ts:239`, and +`catalog/sync.ts:1945`, because the catalog probes one selected runtime and no caller passes a +consumer identity. Resolving a catalog per consumer is a design decision this round does not make. + +1. **#4202 — global pnpm installations cannot self-update.** Carry PR #4203 by `oliver-mee` (open + **draft**, `CHANGES_REQUESTED`, 36 files). **Decision: the keep-set is exactly** `bin/ocx.mjs`, + `src/cli.ts`, `src/cli/launcher-context.ts`, `src/config/pending-teardown.ts`, + `src/lib/bun-runtime.ts`, `src/lib/package-tree-integrity.ts`, `src/service.ts`, every file under + `src/update/`, the tests `tests/ci-workflows/install-scripts.test.ts`, + `tests/cli/ocx-launcher-runtime.test.ts`, `tests/cli/ocx-launcher-source.test.ts`, + `tests/update/update-badge.test.ts`, `tests/update/update-job.test.ts`, + `tests/update/update-pnpm.test.ts`, `tests/update/update-stop-first.test.ts`, the two test-layout + maps, and `docs-site/src/content/docs/getting-started/installation.md`. **Drop** `README.md`, + `structure/01_runtime.md`, `structure/06_docs-and-release.md`, + `docs-site/src/content/docs/getting-started/for-agents.md`, and + `docs-site/src/content/docs/reference/cli/lifecycle.md`. +2. **#4169 — every stop refusal is reported as a `CODEX_HOME` ownership mismatch,** hiding + `respawnable_service`. Carry PR #4170 by `yeongjunyoo` (open **draft**, `REVIEW_REQUIRED`); it + touches `src/cli/index.ts` and `src/lib/process-control.ts`, both yours, plus its two tests + `tests/lib/process-control-graceful.test.ts` and `tests/providers/xai/grok-lifecycle.test.ts`, + which you keep. +3. **#4204 — Windows: a stale persisted CLI 0.135.0 strips max/ultra while Desktop runs 0.153.4.** + The clamp is `src/codex/catalog/effort.ts:441`. #4178 by `luvs01` is open, not a draft, full CI + green, and owns `src/codex/cli-install-provenance.ts`: if it lands first, rebase onto it; + otherwise keep out of that file and say so. +4. **#4207 — the connected catalog reports success while the local Codex CLI rejects unsupported + reasoning levels.** Same clamp as #4204, which is why both are here; client side is + `src/client/hub-client.ts:145`, `src/client/connect.ts:542`, `src/cli/connect.ts:187`. + **Decision: fail closed — block local readiness rather than reporting success** when the + projection is not compatible with the local client. + From 8fa67f9158bb73ab5e44366cb5f692c5ab824b80 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:23:32 +0900 Subject: [PATCH 034/126] fix(bigmodel): admit the Responses Coding Plan preset to the quota reader The BigModel Coding Plan Responses preset is the same domestic subscription as the Chat preset on a different wire, but keyQuotaReaderForProvider() admitted the provider names zai, glm, glm-cn and zhipu-bigmodel-coding only. The destination check already accepted https://open.bigmodel.cn/api/v1, so the only thing standing between the preset and its quota was the name list: providerApiKeyQuotaMode() answered "unsupported" and fetchProviderApiKeyQuotas() returned an empty list before any request was made. Eligibility stays a name list AND the canonical-URL guard. That guard is what keeps BigModel's bare-key Authorization header from travelling to a lookalike host, so a same-named custom provider still dispatches nothing. The glm-5.3-flash half of #4201 is deliberately untouched: the Responses roster is static on purpose and adding Flash needs endpoint-specific evidence this change does not have, so the PR refs the issue rather than closing it. Co-authored-by: Ingwannu <186453546+Ingwannu@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + src/providers/quota.ts | 6 +- tests/fixtures/test-layout-expected.json | 1 + .../zhipu-bigmodel-responses-quota.test.ts | 124 ++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/providers/zhipu-bigmodel-responses-quota.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..acaa20a4f9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1319,6 +1319,7 @@ "z-handler-activation.test.ts": "images", "zcode-client.test.ts": "providers", "zhipu-bigmodel-provider.test.ts": "providers", + "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 08da6d24ac..79e6a6bf91 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -2906,7 +2906,11 @@ function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): K if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota; if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota; if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota; - if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota; + // #4201: the Responses preset is the same domestic GLM Coding Plan subscription on the OpenAI + // Responses wire, so it reads the same monitor endpoint. Eligibility stays a name list AND the + // canonical-URL guard: the guard is what keeps BigModel's bare-key Authorization from reaching a + // lookalike host, so a same-named custom destination still dispatches nothing. + if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota; if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota; if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota; if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..aaf20d5919 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1154,6 +1154,7 @@ "z-handler-activation.test.ts": "images", "zcode-client.test.ts": "providers", "zhipu-bigmodel-provider.test.ts": "providers", + "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" diff --git a/tests/providers/zhipu-bigmodel-responses-quota.test.ts b/tests/providers/zhipu-bigmodel-responses-quota.test.ts new file mode 100644 index 0000000000..18557bd9e5 --- /dev/null +++ b/tests/providers/zhipu-bigmodel-responses-quota.test.ts @@ -0,0 +1,124 @@ +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 { removeTreeWithRetry } from "../helpers/remove-tree"; +import { clearProviderQuotaCache, fetchProviderQuotaReports, providerApiKeyQuotaMode } from "../../src/providers/quota"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +// Issue #4201: the BigModel Coding Plan Responses preset is the same domestic subscription as the +// Chat preset on a different wire, but keyQuotaReaderForProvider() admitted provider names +// zai/glm/glm-cn/zhipu-bigmodel-coding only, so providerApiKeyQuotaMode() answered "unsupported" +// and fetchProviderApiKeyQuotas() returned an empty list before any request was made. The +// destination check already accepted https://open.bigmodel.cn/api/v1; only the name list omitted +// the preset. These cases pin the eligibility AND the guards that make it safe: admitting a name +// must not admit that name on a destination BigModel does not serve, because the domestic monitor +// takes the API key in a bare Authorization header with no scheme. +const RESPONSES_ID = "zhipu-bigmodel-responses"; +const CANONICAL_BASE_URL = "https://open.bigmodel.cn/api/v1"; +const MONITOR_URL = "https://open.bigmodel.cn/api/monitor/usage/quota/limit"; + +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let opencodexHome: string; + +function keyProvider(overrides: Partial = {}): OcxProviderConfig { + return { adapter: "openai-responses", authMode: "key", baseUrl: CANONICAL_BASE_URL, apiKey: "bigmodel-secret", ...overrides }; +} + +function keyQuotaConfig(name: string, provider: OcxProviderConfig): OcxConfig { + return { defaultProvider: name, providers: { [name]: provider } } as OcxConfig; +} + +function quotaLimitsResponse(): Response { + return new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 30, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 60, nextResetTime: 1789600000000 }, + ], + }, + }), { status: 200 }); +} + +beforeEach(() => { + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-bigmodel-responses-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + removeTreeWithRetry(opencodexHome); +}); + +describe("BigModel Responses preset quota eligibility", () => { + test("the preset is probe-eligible on the destination its own registry entry ships", () => { + // Anchored to the registry rather than a hand-typed URL: if the preset's destination ever + // moves, this fails instead of silently proving eligibility for a URL nobody serves. + expect(getProviderRegistryEntry(RESPONSES_ID)?.baseUrl).toBe(CANONICAL_BASE_URL); + expect(providerApiKeyQuotaMode(RESPONSES_ID, keyProvider())).toBe("probe"); + // The Chat preset keeps its existing eligibility; this is an addition, not a swap. + expect(providerApiKeyQuotaMode("zhipu-bigmodel-coding", keyProvider({ + adapter: "openai-chat", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + }))).toBe("probe"); + }); + + test("eligibility still requires a canonical destination and a key auth mode", () => { + for (const provider of [ + // A same-named custom provider pointed somewhere else. + keyProvider({ baseUrl: "https://custom.example.test/api/v1" }), + // The pay-as-you-go endpoint, which is not the Coding Plan subscription. + keyProvider({ baseUrl: "https://open.bigmodel.cn/api/paas/v4" }), + keyProvider({ disabled: true }), + keyProvider({ authMode: "forward" }), + keyProvider({ authMode: "oauth" }), + ]) { + expect(providerApiKeyQuotaMode(RESPONSES_ID, provider)).toBe("unsupported"); + } + }); + + test("the preset probes the domestic monitor endpoint with the bare-key Authorization", async () => { + const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = init?.headers as Record | undefined; + seen.push({ url: String(input), authorization: headers?.Authorization, redirect: init?.redirect }); + return quotaLimitsResponse(); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig(RESPONSES_ID, keyProvider()), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.provider).toBe(RESPONSES_ID); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ fiveHourPercent: 30, weeklyPercent: 60 }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe(MONITOR_URL); + // No "Bearer " prefix: open.bigmodel.cn answers a Bearer header with an auth error (#1168). + expect(seen[0]?.authorization).toBe("bigmodel-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("a same-named custom destination dispatches no quota request at all", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig(RESPONSES_ID, keyProvider({ baseUrl: "https://attacker.example/api/v1" })), + true, + ); + + // The bare key must never travel to a lookalike host, so the guard has to refuse before + // the request, not after reading a response. + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); +}); From 7f91737c2ccdbbb65e0318db8f4b18468447ba1c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:26:33 +0900 Subject: [PATCH 035/126] fix(codex): warm up new ChatGPT Free accounts instead of blaming the credential A ChatGPT account created today has no gpt-5.4-mini and no gpt-5.5, so warmup took a 404 on the default model, found nothing retryable, and told the operator to reauthenticate a login that had just succeeded. gpt-5.6-luna is provisioned on Free and runs in official Codex clients, so it joins FALLBACK_MODELS, and 404 joins 400 as a retryable warmup status behind one shared predicate. The fallback loop now stops on a non-retryable retry error rather than walking the rest of the list, which keeps a mid-chain 401 from being overwritten by a later 404. verifyCodexAccountWarmup no longer claims reauthentication is required when every model was refused for a provisioning reason. It picks its message from isCodexWarmupProvisioningFailure(), which reuses the retry predicate, so widening the retryable set can never leave the operator-facing message disagreeing with the policy that produced the error. The HTTP status stays 401: its only consumer is the OAuth login-state wrapper, which reads the JSON body and ignores the status. Closes #4126 Co-authored-by: chilung-cgu <215337896+chilung-cgu@users.noreply.github.com> --- .../docs/fr/guides/codex-integration.md | 2 +- .../content/docs/guides/codex-integration.md | 2 +- .../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 +- src/codex/auth-api.ts | 8 +- src/codex/warmup.ts | 25 +++++- tests/codex-integration/warmup.test.ts | 83 ++++++++++++++++++- 11 files changed, 117 insertions(+), 15 deletions(-) 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 5964e77137..bd8157e747 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -375,7 +375,7 @@ délégation v1/base/v2 et de ses mécanismes de repli. ## Préchauffage des comptes Codex -L’ajout ou la réauthentification vérifie normalement le compte avant son enregistrement par une petite requête attendant `response.completed`. Le modèle par défaut est `gpt-5.4-mini`, avec un essai sur `gpt-5.5` en cas de HTTP 400. Les erreurs publiques contiennent des catégories fixes, sans corps de réponse brut. +L’ajout ou la réauthentification vérifie normalement le compte avant son enregistrement par une petite requête attendant `response.completed`. Le modèle par défaut est `gpt-5.4-mini`, avec un essai sur `gpt-5.5` et `gpt-5.6-luna` en cas de HTTP 400 ou HTTP 404. Les erreurs publiques contiennent des catégories fixes, sans corps de réponse brut. Si la lecture authentifiée des quotas avec le nouveau jeton OAuth confirme un quota de 5 heures, hebdomadaire ou mensuel épuisé, le compte est enregistré sans appel au modèle et affiche **Validation en attente**. Il reste exclu du routage après un redémarrage ou un renouvellement du jeton. Après récupération du quota, actualisez les quotas : une lecture récente et complète avec de la capacité disponible permet une petite requête de validation. Seule sa réussite active le compte. Tout échec conserve la restriction. Les lectures passives ne déclenchent pas cette requête. Un quota inconnu à l’inscription conserve la vérification habituelle. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 6552b14365..397ef277c6 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -706,7 +706,7 @@ Catalog sync makes the selected sub-agent models available to Codex; see [Codex ## Codex account warmup -When a ChatGPT account is added or reauthenticated, OpenCodex normally verifies it before saving with a small streaming request to the Codex Responses backend. It waits for `response.completed`, defaults to `gpt-5.4-mini`, and retries with `gpt-5.5` on HTTP 400. Public errors contain fixed failure categories rather than raw upstream response bodies. +When a ChatGPT account is added or reauthenticated, OpenCodex normally verifies it before saving with a small streaming request to the Codex Responses backend. It waits for `response.completed`, defaults to `gpt-5.4-mini`, and retries with `gpt-5.5` and `gpt-5.6-luna` on HTTP 400 or HTTP 404. Public errors contain fixed failure categories rather than raw upstream response bodies. If the new OAuth credential's authenticated usage lookup confirms an exhausted 5-hour, weekly, or monthly quota, the account is saved without this model request and shows **Validation pending**. It cannot serve pool requests, even after a restart or token refresh. Once quota recovers, **Refresh quotas** finishes validation: a fresh, complete usage reading with headroom permits one small model request, and only a completed response enables the account. Failed or incomplete readings and failed validation preserve the restriction. Passive account polling does not trigger deferred validation. Unknown usage during initial registration retains the normal warmup gate. 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 ca5d1628d7..58181c0aba 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -241,7 +241,7 @@ ocx service install # persistent: auto-starts on login and respawns on crash ## Codex アカウントのウォームアップ -アカウントの追加・再認証では通常、保存前に小さなモデルリクエストで `response.completed` を確認します。既定モデルは `gpt-5.4-mini` で、HTTP 400 の場合は `gpt-5.5` で再試行します。公開エラーには固定の分類のみを表示し、生の応答本文は公開しません。 +アカウントの追加・再認証では通常、保存前に小さなモデルリクエストで `response.completed` を確認します。既定モデルは `gpt-5.4-mini` で、HTTP 400 または HTTP 404 の場合は `gpt-5.5` および `gpt-5.6-luna` で再試行します。公開エラーには固定の分類のみを表示し、生の応答本文は公開しません。 新しい OAuth トークンによる使用量取得で5時間・週次・月次の上限到達が確認された場合、モデルを呼ばずに保存し、**検証待ち**と表示します。再起動やトークン更新後も使用できません。上限回復後に使用量を更新すると、十分な空き容量を示す完全な最新情報を確認してから小さなモデルリクエストを送り、完了した場合のみ使用可能になります。取得や検証の失敗では待機状態を維持します。通常の状態ポーリングは検証リクエストを送りません。初回登録時の使用量が不明な場合は通常の検証が必要です。 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 c92f789999..391a3e63fc 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -252,7 +252,7 @@ catalog sync는 선택된 서브에이전트 모델을 Codex가 쓸 수 있게 ## Codex 계정 워밍업 -ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 저장 전에 작은 모델 요청으로 확인합니다. `gpt-5.4-mini`의 `response.completed`를 기다리며 HTTP 400이면 `gpt-5.5`로 재시도합니다. 오류에는 고정된 실패 분류만 표시하고 원본 응답 본문은 노출하지 않습니다. +ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 저장 전에 작은 모델 요청으로 확인합니다. `gpt-5.4-mini`의 `response.completed`를 기다리며 HTTP 400 또는 HTTP 404이면 `gpt-5.5`와 `gpt-5.6-luna`로 재시도합니다. 오류에는 고정된 실패 분류만 표시하고 원본 응답 본문은 노출하지 않습니다. 새 OAuth 토큰으로 인증된 사용량 조회에서 5시간·주간·월간 한도 소진이 확인되면 모델 요청 없이 계정을 저장하고 **검증 대기**로 표시합니다. 재시작이나 토큰 갱신 후에도 요청에 사용되지 않습니다. 한도 회복 후 **사용량 새로고침**을 실행하면, 여유가 있는 완전한 최신 사용량을 확인한 뒤 작은 모델 요청을 보내고 완료 응답을 받아야 계정을 사용할 수 있습니다. 조회나 검증 실패 시 대기 상태를 유지합니다. 일반적인 화면 상태 조회는 이 모델 요청을 보내지 않습니다. 최초 등록 때 사용량이 불명확하면 기존 워밍업 검증이 필요합니다. 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 120eb10b61..a22c347f2d 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -368,7 +368,7 @@ v1/base/v2 при делегировании и fallback — в ## Прогрев аккаунтов Codex -При добавлении или повторной аутентификации аккаунт обычно проверяется до сохранения небольшим запросом, ожидающим `response.completed`. По умолчанию используется `gpt-5.4-mini`, при HTTP 400 — повтор с `gpt-5.5`. Публичные ошибки содержат фиксированные категории без исходного тела ответа. +При добавлении или повторной аутентификации аккаунт обычно проверяется до сохранения небольшим запросом, ожидающим `response.completed`. По умолчанию используется `gpt-5.4-mini`, при HTTP 400 или HTTP 404 — повтор с `gpt-5.5` и `gpt-5.6-luna`. Публичные ошибки содержат фиксированные категории без исходного тела ответа. Если запрос квоты с новым OAuth-токеном подтверждает исчерпание пятичасовой, недельной или месячной квоты, аккаунт сохраняется без вызова модели со статусом **Ожидает проверки**. Перезапуск и обновление токена не включают маршрутизацию. После восстановления квоты обновите её: полные свежие данные с доступной ёмкостью разрешают небольшой проверочный запрос. Только успешное завершение включает аккаунт. Ошибки сохраняют ограничение. Пассивный опрос не отправляет такой запрос. Неизвестная квота при регистрации требует обычной проверки. 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 61d2577e84..fcf4f8de66 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -425,7 +425,7 @@ Arayüzü](/tr/guides/sub-agent-surface/) sayfasına bakın. ## Codex hesap ısınması -Hesap ekleme veya yeniden kimlik doğrulama, normalde kaydetmeden önce `response.completed` bekleyen küçük bir model isteğiyle doğrulanır. Varsayılan model `gpt-5.4-mini` olup HTTP 400 durumunda `gpt-5.5` denenir. Genel hatalar ham yanıt gövdesi yerine sabit hata kategorilerini içerir. +Hesap ekleme veya yeniden kimlik doğrulama, normalde kaydetmeden önce `response.completed` bekleyen küçük bir model isteğiyle doğrulanır. Varsayılan model `gpt-5.4-mini` olup HTTP 400 veya HTTP 404 durumunda `gpt-5.5` ve `gpt-5.6-luna` denenir. Genel hatalar ham yanıt gövdesi yerine sabit hata kategorilerini içerir. Yeni OAuth belirteciyle yapılan kota sorgusu 5 saatlik, haftalık veya aylık kotanın tükendiğini doğrularsa hesap model çağrısı olmadan kaydedilir ve **Doğrulama bekleniyor** gösterilir. Yeniden başlatma veya belirteç yenileme yönlendirmeyi açmaz. Kota geri geldiğinde kotaları yenileyin: kullanılabilir kapasite gösteren eksiksiz güncel veri küçük bir doğrulama isteğine izin verir. Yalnızca tamamlanan yanıt hesabı etkinleştirir. Hatalarda kısıtlama korunur. Pasif sorgulama bu isteği göndermez. İlk kayıtta bilinmeyen kota normal doğrulamayı gerektirir. 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 f63ace3586..bfe76b0c72 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 @@ -312,7 +312,7 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 ## Codex 账号预热 -添加或重新认证账号时,通常会在保存前发送一个小型模型请求并等待 `response.completed`。默认使用 `gpt-5.4-mini`,HTTP 400 时改用 `gpt-5.5` 重试。公开错误仅包含固定分类,不包含原始响应正文。 +添加或重新认证账号时,通常会在保存前发送一个小型模型请求并等待 `response.completed`。默认使用 `gpt-5.4-mini`,HTTP 400 或 HTTP 404 时改用 `gpt-5.5` 与 `gpt-5.6-luna` 重试。公开错误仅包含固定分类,不包含原始响应正文。 如果新 OAuth 凭据的已认证用量查询确认5小时、每周或每月额度耗尽,则不调用模型而直接保存账号,显示**等待验证**。重启或刷新令牌也不会使其可用。额度恢复后刷新额度:只有完整的最新用量显示有余额,才会发送一个小型验证请求;请求完成后账号才可用于路由。查询或验证失败将保留等待状态。普通状态轮询不会发送该请求。初次注册时用量未知仍需常规预热验证。 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 999295cd39..708341e5bf 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 @@ -319,7 +319,7 @@ ocx service install # 常駐:登入時自動啟動,崩潰後自動重新 ## Codex 帳號預熱 -新增或重新驗證帳號時,通常會在儲存前傳送小型模型請求並等待 `response.completed`。預設使用 `gpt-5.4-mini`,HTTP 400 時改用 `gpt-5.5` 重試。公開錯誤僅包含固定分類,不包含原始回應本文。 +新增或重新驗證帳號時,通常會在儲存前傳送小型模型請求並等待 `response.completed`。預設使用 `gpt-5.4-mini`,HTTP 400 或 HTTP 404 時改用 `gpt-5.5` 與 `gpt-5.6-luna` 重試。公開錯誤僅包含固定分類,不包含原始回應本文。 若新 OAuth 憑證的已驗證用量查詢確認5小時、每週或每月額度耗盡,則不呼叫模型而直接儲存帳號,顯示**等待驗證**。重新啟動或更新權杖也不會使其可用。額度恢復後重新整理額度:只有完整的最新用量顯示有餘額,才會傳送小型驗證請求;請求完成後帳號才可用於路由。查詢或驗證失敗將保留等待狀態。一般狀態輪詢不會傳送該請求。首次註冊時用量未知仍需一般預熱驗證。 diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 6994e2b2ac..79fca9470f 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -116,7 +116,7 @@ import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome"; import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock"; import { observeMainReserveRevocation } from "./reserve-availability"; import { emailMaskingEnabled, projectEmail } from "../lib/privacy"; -import { codexWarmupFailureReason, warmCodexAccount } from "./warmup"; +import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "./warmup"; export { maskEmail } from "../lib/privacy"; import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types"; import type { CatalogDisposition } from "./convergence-types"; @@ -588,7 +588,11 @@ async function verifyCodexAccountWarmup( return { ok: false, response: jsonResponse({ - error: "Codex account warmup failed. Reauthenticate the account and try again.", + // Every fallback model was refused for a provisioning reason, so telling the operator to + // reauthenticate sends them back through a login that already succeeded. + error: isCodexWarmupProvisioningFailure(err) + ? "Codex account warmup failed. Verify account model access or provisioning and try again." + : "Codex account warmup failed. Reauthenticate the account and try again.", code: "codex_warmup_failed", reason, accountId, diff --git a/src/codex/warmup.ts b/src/codex/warmup.ts index 5af42490b4..2fbd87d582 100644 --- a/src/codex/warmup.ts +++ b/src/codex/warmup.ts @@ -28,7 +28,8 @@ export interface CodexWarmupOptions { const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; const DEFAULT_MODEL = "gpt-5.4-mini"; -const FALLBACK_MODELS = ["gpt-5.5"]; +const FALLBACK_MODELS = ["gpt-5.5", "gpt-5.6-luna"]; +const isRetryableWarmupStatus = (status?: number): boolean => status === 400 || status === 404; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_TIMEOUT_MS = 0x7fff_ffff; const MAX_ERROR_BODY_BYTES = 2048; @@ -63,6 +64,17 @@ export function codexWarmupFailureReason(err: unknown): string { return safeWarmupReason(err); } +/** + * 400 and 404 mean the model or the account is not provisioned for this request, not that the + * credential is bad. Sharing the retry predicate keeps the operator-facing diagnostic from + * disagreeing with the fallback policy that produced the final error. + */ +export function isCodexWarmupProvisioningFailure(err: unknown): boolean { + return err instanceof CodexWarmupError + && err.code === "http_status" + && isRetryableWarmupStatus(err.status); +} + function eventTypeFromData(data: unknown): string | undefined { if (!data || typeof data !== "object") return undefined; const record = data as Record; @@ -286,8 +298,8 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise { expect(fetchMock).toHaveBeenCalledTimes(2); expect(parsedBodies.map(body => body.model)).toEqual(["gpt-5.4-mini", "gpt-5.5"]); }); + test("warmCodexAccount retries FALLBACK_MODELS on HTTP 404 and falls through to gpt-5.6-luna", async () => { + const parsedBodies: Record[] = []; + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as Record; + parsedBodies.push(body); + + if (body.model === "gpt-5.4-mini") { + return new Response(JSON.stringify({ detail: "model not found" }), { status: 404 }); + } + if (body.model === "gpt-5.5") { + return new Response(JSON.stringify({ detail: "model not supported for free tier" }), { status: 400 }); + } + if (body.model === "gpt-5.6-luna") { + return sseResponse(); + } + return new Response("unexpected model", { status: 500 }); + }); + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch); + + try { + await warmCodexAccount({ accessToken: "access-test", chatgptAccountId: "acct-test" }); + } finally { + fetchSpy.mockRestore(); + } + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(parsedBodies.map(body => body.model)).toEqual(["gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna"]); + }); + + test("warmCodexAccount does not retry on 401 and immediately fails", async () => { + const parsedBodies: Record[] = []; + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as Record; + parsedBodies.push(body); + return new Response(JSON.stringify({ detail: "unauthorized" }), { status: 401 }); + }); + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch); + + try { + await warmCodexAccount({ accessToken: "access-test", chatgptAccountId: "acct-test" }); + expect.unreachable("expected warmCodexAccount to throw"); + } catch (err) { + expect(err).toBeInstanceOf(CodexWarmupError); + expect((err as CodexWarmupError).status).toBe(401); + } finally { + fetchSpy.mockRestore(); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("codex warmup failure classification", () => { + test("an exhausted retryable status classifies as provisioning, not as a bad credential", () => { + for (const status of [400, 404]) { + expect(isCodexWarmupProvisioningFailure( + new CodexWarmupError("http_status", "Codex warmup was rejected", { status }), + )).toBe(true); + } + }); + + test("an authentication status still asks the operator to reauthenticate", () => { + for (const status of [401, 403, 429, 500]) { + expect(isCodexWarmupProvisioningFailure( + new CodexWarmupError("http_status", "Codex warmup was rejected", { status }), + )).toBe(false); + } + }); + + test("a stream or transport failure is never reported as a provisioning problem", () => { + expect(isCodexWarmupProvisioningFailure(new CodexWarmupError("transport"))).toBe(false); + expect(isCodexWarmupProvisioningFailure(new CodexWarmupError("no_terminal"))).toBe(false); + expect(isCodexWarmupProvisioningFailure(new CodexWarmupError("http_status"))).toBe(false); + expect(isCodexWarmupProvisioningFailure(new Error("boom"))).toBe(false); + expect(isCodexWarmupProvisioningFailure(undefined)).toBe(false); + }); }); From d4d52fcf1794bf352b8bc052264496f7208d08c9 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:26:33 +0900 Subject: [PATCH 036/126] docs(devlog): record the L3 WP1 carry and the diagnostic gap it closed --- .../010_wp1_4126_warmup.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 devlog/_plan/260911_l3_account_pool/010_wp1_4126_warmup.md diff --git a/devlog/_plan/260911_l3_account_pool/010_wp1_4126_warmup.md b/devlog/_plan/260911_l3_account_pool/010_wp1_4126_warmup.md new file mode 100644 index 0000000000..c9589637f8 --- /dev/null +++ b/devlog/_plan/260911_l3_account_pool/010_wp1_4126_warmup.md @@ -0,0 +1,56 @@ +# WP1 — #4126 newly created ChatGPT Free account fails Codex warmup with HTTP 404 + +## What the issue reports + +OAuth login succeeds, then warmup fails with `http_status:404` and the dialog tells the operator to +reauthenticate. The account is a brand-new ChatGPT Free account that works normally on chatgpt.com, +so the credential was never the problem. + +## Carried work + +PR #4188 by `chilung-cgu` (open draft, reset by the readiness gate rather than rejected). Every file +it touches — `src/codex/warmup.ts`, `tests/codex-integration/warmup.test.ts`, and the eight +`codex-integration.md` locale pages — is inside the L3 owned list, so **no hunks were dropped**. It +applied cleanly onto the rebased lane branch with `git apply -3`. + +What it changes: + +- `FALLBACK_MODELS` gains `gpt-5.6-luna`, which a Free account can actually run. +- 404 joins 400 as a retryable warmup status, behind one shared `isRetryableWarmupStatus()` predicate. +- The fallback loop stops on a non-retryable retry error instead of walking the rest of the list. + +## The gap this lane closed + +PR #4188's description claims it updates `verifyCodexAccountWarmup` in `src/codex/auth-api.ts` to +stop claiming reauthentication is required. Its diff contains no such change. The issue asks for the +same thing directly: distinguish 401/403 authentication failures from 404 provisioning failures. + +`src/codex/auth-api.ts` is an L3 owned path, so this lane finished it. `warmup.ts` now exports +`isCodexWarmupProvisioningFailure()`, which reuses the retry predicate, and `verifyCodexAccountWarmup` +picks its message from it. Reusing the predicate is the point: a future change to the retryable set +cannot leave the operator-facing message disagreeing with the policy that produced the error. + +The HTTP status stays 401. The only consumer is the OAuth login-state wrapper at +`auth-api.ts:2845`, which reads the JSON body and ignores the status, so changing it would have been +an unrelated contract move with no caller asking for it. + +## Audit + +A read-only `xai/grok-4.6` subagent reviewed the staged diff against five questions. Verdict: pass. + +- The early throw changes no caller's outcome for the worse. `token-guardian.ts:264` persists + `codexWarmupFailureReason`, and a mid-chain 401 is now preserved instead of being overwritten by a + later 404. `quota-auto-refresh.ts:180` and `:217` branch on `error.status === 401` and now see it + sooner. The `http_status:401`/`http_status:403` branch at `auth-api.ts:1573` stays reachable. +- A repeating 404 costs at most 3 upstream POSTs per `warmCodexAccount()` call, bounded by + `quota-auto-refresh`'s 5-minute retry floor and `token-guardian`'s 300s–3600s backoff. +- `isCodexWarmupProvisioningFailure(new CodexWarmupError("http_status"))` is `false`, which is + correct: production always carries `res.status`, and a statusless construct is not evidence of a + provisioning refusal. +- No existing test asserts the old message, the 401 on that response, or a one-entry + `FALLBACK_MODELS`. + +## Verification + +Local suite, typecheck, and build: NOT RUN by operator instruction. Hosted CI on the pushed head is +the evidence. From bf7585a440a08bc657ff181f6484e4e7c1f71541 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:35:33 +0900 Subject: [PATCH 037/126] fix(update): support native pnpm self-updates Global pnpm installations cannot self-update: the updater treats every node_modules installation as npm and forwards npm-only flags (--allow-scripts=bun, --no-audit, --no-fund) to pnpm's global add, which rejects them. The failure lands after the proxy has already been stopped. Carried from #4203 by @oliver-mee, restricted to the update, launcher, service and CLI surface. The install detector now recognises pnpm's isolated, store-link, preserved-symlink and hoisted-group layouts; pnpm gets a native global update path that owns its own group, shims and rollback; and registry integrity is checked before the proxy is stopped rather than after. The shared install-tree verifier is split rather than shared, which answers the blocking review on #4203. verifyInstallTree stays confined to the candidate's own tree: Node's resolver walks the ancestor directory chain, so a global npm candidate at /lib/node_modules/@scope/pkg could otherwise satisfy its bundled-Bun requirement from /lib/node_modules/bun, which belongs to a different package. Three decisions read that verdict - accepting the stage before the swap, rolling back after it, and reaping the only backup at boot - so a non-self-contained candidate called healthy costs the known-good copy. verifyPnpmInstallTree keeps out-of-package resolution, because pnpm legitimately exposes dependencies through a virtual store, a package-root symlink or a hoisted group, but bounds it: the dependency must be reachable through a root this package instance owns, and an enclosing node_modules counts only when pnpm's own bookkeeping (.pnpm or .modules.yaml) claims it. Ownership is probed lexically rather than filtered from require.resolve output, because the resolver reports the realpath of the resolved file and a dependency reached through pnpm's own symlink comes back as a virtual-store path that no lexical ownership test can recognise. Refs #4203 Closes #4202 Co-authored-by: Oliver Mee <102673257+oliver-mee@users.noreply.github.com> --- bin/ocx.mjs | 293 +++++++-- .../010_wp1_pnpm_self_update.md | 88 +++ .../docs/getting-started/installation.md | 8 +- scripts/test-layout/layout.json | 2 + src/cli.ts | 4 +- src/cli/launcher-context.ts | 2 +- src/config/pending-teardown.ts | 2 +- src/lib/bun-runtime.ts | 2 +- src/lib/package-tree-integrity.ts | 2 +- src/service.ts | 2 +- src/update/badge.ts | 5 +- src/update/index.ts | 381 +++++++++-- src/update/install-detection.d.mts | 6 + src/update/install-detection.mjs | 73 +++ src/update/job.ts | 150 +++-- src/update/pnpm-global-install.d.mts | 144 ++++ src/update/pnpm-global-install.mjs | 591 +++++++++++++++++ src/update/pnpm-invocation.d.mts | 43 ++ src/update/pnpm-invocation.mjs | 141 ++++ src/update/registry-integrity.d.mts | 16 + src/update/registry-integrity.mjs | 37 ++ src/update/transactional-install.d.mts | 2 +- src/update/transactional-install.mjs | 97 ++- src/update/tray-update-plan.mjs | 2 +- tests/ci-workflows/install-scripts.test.ts | 6 +- tests/cli/ocx-launcher-runtime.test.ts | 4 +- tests/cli/ocx-launcher-source.test.ts | 11 +- tests/fixtures/test-layout-expected.json | 2 + tests/update/update-badge.test.ts | 2 +- tests/update/update-job.test.ts | 100 ++- tests/update/update-pnpm.test.ts | 620 ++++++++++++++++++ tests/update/update-stop-first.test.ts | 16 +- tests/update/update-tree-ownership.test.ts | 179 +++++ 33 files changed, 2814 insertions(+), 219 deletions(-) create mode 100644 devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md create mode 100644 src/update/install-detection.d.mts create mode 100644 src/update/install-detection.mjs create mode 100644 src/update/pnpm-global-install.d.mts create mode 100644 src/update/pnpm-global-install.mjs create mode 100644 src/update/pnpm-invocation.d.mts create mode 100644 src/update/pnpm-invocation.mjs create mode 100644 src/update/registry-integrity.d.mts create mode 100644 src/update/registry-integrity.mjs create mode 100644 tests/update/update-pnpm.test.ts create mode 100644 tests/update/update-tree-ownership.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 59818de2f5..ef3aa80cd8 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node /** - * opencodex npm bin launcher. + * opencodex published-package bin launcher. * * The package source is TypeScript that runs on the Bun runtime. To let - * `npm install -g @bitkyc08/opencodex` work without a separately-installed Bun, + * global npm and pnpm installs of `@bitkyc08/opencodex` work without a separately-installed Bun, * we bundle the runtime via the `bun` npm dependency and exec it from this * Node shim. (Dev still runs `bun run src/cli/index.ts` directly via the shebang on - * src/cli/index.ts — only the published npm `bin` routes through here.) + * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.) */ import { spawn, spawnSync } from "node:child_process"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; @@ -20,6 +20,14 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; +import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; +import { detectInstallFromPath } from "../src/update/install-detection.mjs"; +import { + pnpmOwnerInvocation, + resolvePnpmGlobalOwner, + runPnpmGlobalUpdate, +} from "../src/update/pnpm-global-install.mjs"; +import { checkRegistryPackageIntegrity } from "../src/update/registry-integrity.mjs"; import { hasPendingTeardownIn } from "../src/config/pending-teardown-names.mjs"; import { npmCachePreflightFailureMessage, @@ -44,6 +52,7 @@ try { } const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); +const installMethod = detectInstallFromPath(here, { exists: existsSync }); const cliPath = join(here, "..", "src", "cli", "index.ts"); const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT"; const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof="; @@ -53,7 +62,7 @@ function isNodeModulesInstall() { } function isBunGlobalInstall() { - return /[\\/]\.bun[\\/]/.test(here); + return installMethod === "bun"; } function currentPackageVersion() { @@ -102,10 +111,9 @@ function historyRestoreIncomplete() { } } -function repairCodexShimIfNeeded() { +function repairCodexShimIfNeeded(launcherPath = fileURLToPath(import.meta.url)) { if (!shouldRepairCodexShim()) return; - const launcher = fileURLToPath(import.meta.url); - const res = spawnSync(process.execPath, [launcher, "codex-shim", "install"], { + const res = spawnSync(process.execPath, [launcherPath, "codex-shim", "install"], { stdio: "inherit", windowsHide: true, }); @@ -135,34 +143,116 @@ function runTrayLifecycle(launcher, action) { }); } +function shellQuote(value) { + if (process.platform === "win32") return `"${value.replaceAll("\"", "\\\"")}"`; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function launcherStartHint(launcher, port) { + return `${shellQuote(process.execPath)} ${shellQuote(launcher)} start --port ${Math.trunc(port)}`; +} + function runNpmSelfUpdate() { + return runPackageManagerSelfUpdate("npm"); +} + +function runPnpmSelfUpdate() { + return runPackageManagerSelfUpdate("pnpm"); +} + +function runningPnpmShimPath() { + const invoked = process.argv[1]; + if (!invoked) return undefined; + const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase(); + if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) { + return undefined; + } + return resolve(invoked); +} + +function runPackageManagerSelfUpdate(manager) { const current = currentPackageVersion(); const tag = updateTag(current); - const latestInvocation = npmInvocation(["view", `${PKG}@${tag}`, "version"]); - const installInvocation = npmInvocation(["install", "-g", `${PKG}@${tag}`]); + let owner; + if (manager === "pnpm") { + const ownerResult = resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath: resolve(here, ".."), + commandPaths: resolvePnpmCommands(), + runningShimPath: runningPnpmShimPath(), + runPnpm: (commandPath, args, capture = false) => { + const invocation = pnpmInvocationForPath(commandPath, args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + stdio: capture ? "pipe" : "ignore", + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + ...invocation.options, + }); + }, + }); + if (!ownerResult.ok) { + console.error(`opencodex: ${ownerResult.reason}; aborting before stopping the proxy.`); + process.exit(1); + } + owner = ownerResult.owner; + } + const managerInvocation = args => manager === "pnpm" + ? pnpmOwnerInvocation(owner, args) + : npmInvocation(args); + const latestInvocation = managerInvocation(["view", `${PKG}@${tag}`, "version"]); + const installArgs = manager === "pnpm" + ? ["add", "-g", "--allow-build=bun", `${PKG}@${tag}`] + : ["install", "-g", `${PKG}@${tag}`]; + const installInvocation = managerInvocation(installArgs); if (!latestInvocation || !installInvocation) { - console.error("opencodex: could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy."); + console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`); process.exit(1); } const latestResult = spawnSync(latestInvocation.file, latestInvocation.args, { encoding: "utf8", timeout: 12000, windowsHide: true, + ...(latestInvocation.env ? { env: latestInvocation.env } : {}), ...latestInvocation.options, }); - const latest = latestResult.status === 0 ? latestResult.stdout.trim() : ""; + const latest = latestResult.status === 0 && typeof latestResult.stdout === "string" ? latestResult.stdout.trim() : ""; - console.log(`opencodex v${current} (installed via npm, tag ${tag})`); + console.log(`opencodex v${current} (installed via ${manager}, tag ${tag})`); if (latest && latest === current) { console.log(`Already on the latest ${tag} version (v${latest}).`); process.exit(0); } - const cachePreflight = runNpmCachePreflight(); - if (!cachePreflight.ok) { - console.error(`opencodex: ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`); + const integrity = checkRegistryPackageIntegrity(PKG, latest || null, args => { + const invocation = managerInvocation(args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + encoding: "utf8", + timeout: 12000, + windowsHide: true, + ...(invocation.env ? { env: invocation.env } : {}), + ...invocation.options, + }); + }); + if (integrity.ok === false) { + console.error(`opencodex: ${integrity.reason}; aborting before stopping the proxy.`); process.exit(1); } + if (integrity.ok === "skipped") { + console.warn(`opencodex: integrity pre-flight skipped: ${integrity.reason}. Proceeding best-effort.`); + } else { + console.log(`Verified ${PKG}@${latest} integrity metadata ${integrity.integrity.slice(0, 24)}…`); + } + + if (manager === "npm") { + const cachePreflight = runNpmCachePreflight(); + if (!cachePreflight.ok) { + console.error(`opencodex: ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`); + process.exit(1); + } + } // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` // unloads it, so a successful update must refresh and restart it afterwards. @@ -177,15 +267,15 @@ function runNpmSelfUpdate() { * may be re-registered and require elevation. */ function serviceRefreshArgs() { - return [launcher, "service", "repair"]; + return [postUpdateLauncher, "service", "repair"]; } /** 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 [launcher, "service", "install", "--native"]; + if (state.backend === "native") return [postUpdateLauncher, "service", "install", "--native"]; } catch { /* missing or corrupt — fall through to default */ } - return [launcher, "service", "install"]; + return [postUpdateLauncher, "service", "install"]; } /** * Structured "is a service actually registered?" answer. @@ -262,16 +352,23 @@ function runNpmSelfUpdate() { // get it from one place. const launcher = fileURLToPath(import.meta.url); + // The pnpm owner preflight has verified this package tree and global group. Keep that exact + // package path as the recovery starting point; a path returned by the pnpm transaction + // replaces it only after the new tree and shims have been verified. + let postUpdateLauncher = manager === "pnpm" && owner + ? join(owner.packagePath, "bin", "ocx.mjs") + : launcher; + let postUpdateLauncherUsable = true; function startProxyDirectly() { - if (!existsSync(launcher)) { + if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); return; } const env = { ...process.env }; delete env.OCX_SERVICE; console.log(`Attempting to restart the proxy on port ${bakePort}.`); - const child = spawn(process.execPath, [launcher, "start", "--port", String(bakePort)], { + const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], { detached: true, stdio: "ignore", windowsHide: true, @@ -296,7 +393,7 @@ function runNpmSelfUpdate() { // diagnostic says the service is genuinely absent. Installing after ANY repair // failure would resurrect the elevation prompt this change exists to avoid, and // could re-register a service the user just uninstalled. - if (svc.status !== 0 && readServiceInstalledFromStatus(launcher) === false) { + 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 }); } @@ -305,7 +402,7 @@ function runNpmSelfUpdate() { // Exit 0 can still leave stale/missing assets that never bring the proxy // back — match the GUI/CLI fallthrough so /healthz is not left dead. try { - const st = spawnSync(process.execPath, [launcher, "status", "--json"], { + const st = spawnSync(process.execPath, [postUpdateLauncher, "status", "--json"], { encoding: "utf8", timeout: 20_000, windowsHide: true, @@ -365,6 +462,10 @@ function runNpmSelfUpdate() { existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); function recoverStoppedRuntimeAfterFailure() { + if (!postUpdateLauncherUsable) { + console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); + return; + } if (serviceWasInstalled) { console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service."); refreshBackgroundServiceOrStartDirect(); @@ -423,62 +524,105 @@ function runNpmSelfUpdate() { } } - // #1942/#1849: stage -> verify -> swap -> rollback instead of installing straight - // into the live tree. A failure at any point leaves either the old or the new tree - // complete — never a file-less skeleton. Falls back to the legacy in-place install - // only when the transactional module cannot run at all. - const packageDir = resolve(here, ".."); - console.log(`Updating${latest ? ` to v${latest}` : ""} (transactional)...`); + // 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 { - 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), - }); - 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 }; + 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 { - console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); - 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, { + 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 }; + } 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. - console.error(`opencodex: transactional update failed unexpectedly (${error?.message ?? error}). ` + - `The live install was not knowingly modified; run 'ocx update' again or reinstall with ` + - `npm install -g --allow-scripts=bun ${PKG}@${tag}.`); + 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) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); - repairCodexShimIfNeeded(); + repairCodexShimIfNeeded(postUpdateLauncher); if (trayBeforeUpdate.refreshAfterReplacement) { - const tray = spawnSync(process.execPath, [launcher, ...trayBeforeUpdate.installArgs], { + const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayBeforeUpdate.installArgs], { stdio: "inherit", windowsHide: true, }); if (tray.status !== 0) { console.warn("opencodex: Windows tray refresh failed. Run: ocx tray install"); - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); } } // The stop above unloaded any managed service; refresh via the freshly-installed @@ -487,19 +631,22 @@ function runNpmSelfUpdate() { console.log("Refreshing the background service with the updated files..."); refreshBackgroundServiceOrStartDirect(); } else { - console.log("Restart the proxy: ocx start"); + console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } process.exit(0); } - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); recoverStoppedRuntimeAfterFailure(); - console.error(`\nUpdate failed (npm exit ${res.status ?? "?"}). Try manually: npm install -g --allow-scripts=bun ${PKG}@${tag}`); + const manual = manager === "pnpm" + ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` + : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; + console.error(`\nUpdate failed (${manager} exit ${res.status ?? "?"}). Try manually: ${manual}`); process.exit(1); } function bunBinDir() { // Resolve the `bun` dependency's directory without hardcoding the platform - // package — npm's os/cpu/libc resolution already picked the right @oven/bun-*. + // package — the package manager's os/cpu/libc resolution already picked the right @oven/bun-*. return dirname(require.resolve("bun/package.json")); } @@ -511,7 +658,7 @@ const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH"; function findBunBinary(bunDir) { - // The npm `bun` package ships the binary as bin/bun.exe on every platform; + // The bundled `bun` package ships the binary as bin/bun.exe on every platform; // probe bin/bun too for forward compatibility. for (const name of ["bun.exe", "bun"]) { const p = join(bunDir, "bin", name); @@ -521,12 +668,15 @@ function findBunBinary(bunDir) { } function fail(msg) { + const reinstall = installMethod === "pnpm" + ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" + : "npm install -g --allow-scripts=bun @bitkyc08/opencodex"; console.error( `opencodex: ${msg}\n` + "The bundled Bun runtime could not be prepared. This usually means the\n" + - "install skipped lifecycle scripts (e.g. npm blocked bun's postinstall\n" + - "under allowScripts) or optional dependencies. Reinstall with:\n" + - " npm install -g --allow-scripts=bun @bitkyc08/opencodex\n" + + "install skipped lifecycle scripts (for example npm blocked bun's postinstall\n" + + "or pnpm did not approve bun's build) or optional dependencies. Reinstall with:\n" + + ` ${reinstall}\n` + "(use sudo if the original install used sudo; without --ignore-scripts\n" + "and without --omit=optional / optional=false)" ); @@ -534,7 +684,7 @@ function fail(msg) { } function resolveBun({ allowInstall = true } = {}) { - // Keep direct npm-launcher starts aligned with durable service/shim installs: + // Keep direct package-launcher starts aligned with durable service/shim installs: // a valid explicit runtime must win even when the bundled dependency exists. const override = process.env[BUN_OVERRIDE_ENV]?.trim(); if (override) { @@ -566,7 +716,7 @@ function resolveBun({ allowInstall = true } = {}) { return { path: bin, source: "bundled" }; } -// `ocx update --help` prints usage and exits WITHOUT side effects. The npm launcher +// `ocx update --help` prints usage and exits WITHOUT side effects. The Node launcher // intercepts `update` before the Bun CLI starts, so the help short-circuit must live // here too — otherwise --help runs the real self-update, stops the proxy, and drops // in-flight routed streams (issue #168). @@ -584,13 +734,14 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { } if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { - runNpmSelfUpdate(); + if (installMethod === "npm") runNpmSelfUpdate(); + if (installMethod === "pnpm") runPnpmSelfUpdate(); } // #1849 boot probe: a prior update that lost power (or double-faulted) mid-swap leaves a // backup sibling and a broken live tree. Restore before anything tries to run from the // broken tree; reap stale backups once the live tree verifies healthy. -if (!codexCliUpdateInspection && isNodeModulesInstall() && !isBunGlobalInstall()) { +if (!codexCliUpdateInspection && installMethod === "npm" && isNodeModulesInstall() && !isBunGlobalInstall()) { try { const probe = bootRestoreProbe(resolve(here, "..")); if (probe.action === "restored") { diff --git a/devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md b/devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md new file mode 100644 index 0000000000..2853da3578 --- /dev/null +++ b/devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md @@ -0,0 +1,88 @@ +# wp1 — #4202: global pnpm installations cannot self-update + +Work-phase 1 of the L4 lane. Base: `origin/dev` after #4226 and #4227 landed. Carried source: +PR #4203 by `oliver-mee`, head `e74c6e54d`, two commits on base `f94dd88f1`. + +## What the issue asks for + +`ocx update` on a pnpm global installation forwards npm-only flags (`--allow-scripts=bun`, +`--no-audit`, `--no-fund`) to pnpm, which rejects them. The failure lands **after** the proxy has +already been stopped. #4202 asks for either a pnpm-native global update path or a safe actionable +error raised before the proxy is stopped. + +## Keep-set, verbatim from the packet + +31 of the carry's 36 files. Kept: `bin/ocx.mjs`, `src/cli.ts`, `src/cli/launcher-context.ts`, +`src/config/pending-teardown.ts`, `src/lib/bun-runtime.ts`, `src/lib/package-tree-integrity.ts`, +`src/service.ts`, every file under `src/update/`, the seven carried tests, the two test-layout maps, +and `docs-site/src/content/docs/getting-started/installation.md`. + +Dropped, because L4 does not own them: `README.md`, `structure/01_runtime.md`, +`structure/06_docs-and-release.md`, `docs-site/src/content/docs/getting-started/for-agents.md`, +`docs-site/src/content/docs/reference/cli/lifecycle.md`. + +`git diff f94dd88f1..e74c6e54d` restricted to the keep-set is 3676 lines and +`git apply --check` reports no conflict against the rebased branch: no keep-set path moved on +`dev` between the carry's base and the current tip. + +## The blocking finding this work-phase must fold in + +`Ingwannu` (repository owner) requested changes on #4203: + +> In src/update/transactional-install.mjs, verifyInstallTree now delegates to +> dependencyPackageDir/createRequire.resolve. That resolution can find dependencies in ancestor +> node_modules outside the candidate package tree. […] A candidate missing its own bundled Bun or +> sentinel dependency must not pass merely because an ancestor installation supplies one; otherwise +> staging/boot recovery can call a non-self-contained candidate healthy and discard or replace the +> known-good copy. + +The finding is structural, not stylistic. In the carry both exported verifiers are the same +function: `verifyInstallTree` and `verifyPnpmInstallTree` each call +`verifyInstallTreeWithDependencyRoot`, which resolves `bun` and the sentinel deps through +`createRequire(...).resolve`. Node's resolution walks the ancestor directory chain, so for a global +npm layout a candidate at `/lib/node_modules/@bitkyc08/opencodex` can satisfy its bun +requirement from `/lib/node_modules/bun`, which belongs to a different package. + +Three decisions consume that boolean, all on the npm path: + +- `transactional-install.mjs:198` accepts the staged tree (D2, before the swap). +- `transactional-install.mjs:241` re-verifies the live tree after the swap and decides rollback. +- `transactional-install.mjs:121`, inside `bootRestoreProbe`, decides that the live tree is healthy + and **reaps every backup**, which is the only known-good copy. + +So an over-permissive verdict is not cosmetic: it can accept a stage that cannot start, then delete +the backup that would have recovered it. + +## Plan + +1. Apply the keep-set diff unchanged. +2. Split the verifier in `src/update/transactional-install.mjs` into two real implementations that + share the manifest checks but not the dependency-resolution policy: + - `verifyInstallTree` (npm, and every caller above) returns to the strict pre-carry rule: a + sentinel dependency counts only at `/node_modules//package.json`, and the + bun size gate reads only `/node_modules/bun`. Ancestor resolution cannot satisfy it. + - `verifyPnpmInstallTree` keeps resolver-based discovery, because pnpm legitimately exposes + dependencies through a virtual store, a package-root symlink, or a hoisted group root, but adds + the ownership check the review asked for: the resolved dependency must live under a dependency + root that this package instance owns — its own `node_modules`, its realpath's `node_modules`, + or the `node_modules` that encloses the package when that root carries pnpm's own metadata + (`.pnpm` or `.modules.yaml`). An ancestor root with no pnpm evidence is refused. +3. Regression test at `tests/update/update-tree-ownership.test.ts`, registered in + `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`, covering the + three cases the review named: a candidate missing its own bun with an unrelated ancestor bun + present, a truncated candidate bun with an intact ancestor bun, a legitimate pnpm virtual-store + and hoisted layout, and the `bootRestoreProbe` decision — the probe must restore the backup + rather than reap it when the live tree is not self-contained. +4. Commit with a `Co-authored-by` trailer for `oliver-mee`, push with `--no-verify`, open the PR + against `dev` with `Closes #4202`. + +## Diff level + +`src/update/transactional-install.mjs` ~60 lines changed on top of the carry; one new test file; +two one-line map registrations. Everything else is the carry verbatim. + +## Not run + +`bun test`, `bun run test`, `bun run test:changed`, `bun run typecheck`, `bun run build:gui` and +`bun install` are NOT RUN by operator instruction. Hosted CI on the exact pushed head is the only +product evidence this round accepts. diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 7c68e1b840..4ef61c5566 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -11,7 +11,7 @@ vision and web-search sidecars can also use your ChatGPT login when a routed mod | Requirement | Why | | --- | --- | -| **[Node](https://nodejs.org) ≥ 18** | `ocx` runs on the Bun runtime, but the runtime is bundled automatically on `npm install` — you do **not** need to install Bun yourself. | +| **[Node](https://nodejs.org) ≥ 18** | `ocx` runs on the Bun runtime, but the runtime is bundled automatically by the npm or pnpm install — you do **not** need to install Bun yourself. | | **[OpenAI Codex](https://openai.com/codex)** (CLI, App, or SDK) | The client opencodex sits in front of. opencodex writes to `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`). | | A provider account or API key | Anthropic, xAI, Kimi, Ollama Cloud, OpenRouter, an OpenAI-compatible endpoint, or your ChatGPT login. | @@ -21,6 +21,12 @@ vision and web-search sidecars can also use your ChatGPT login when a routed mod npm install -g @bitkyc08/opencodex ``` +With pnpm 10.4 or later: + +```bash +pnpm add -g --allow-build=bun @bitkyc08/opencodex +``` + :::note[npm blocked the bun postinstall?] Recent npm versions may block bun's postinstall script (`npm warn install-scripts ... blocked because they are not covered by allowScripts`), diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..135aeaba5c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1232,10 +1232,12 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", "update-transactional.test.ts": "update", "update-tray-handoff.test.ts": "update", + "update-tree-ownership.test.ts": "update", "upstream-connect-error.test.ts": "server", "upstream-http-error.test.ts": "adapters", "upstream-http-version.test.ts": "server", diff --git a/src/cli.ts b/src/cli.ts index 2f060ab6aa..a852d7c80f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,8 @@ // // Before the src/ restructure the CLI lived at src/cli.ts, and durable launchers // (codex shim wrappers, installed service definitions) baked that absolute path -// into their command lines. Users who upgrade in place with a bare -// `npm install -g @bitkyc08/opencodex` (instead of `ocx update`, which repairs the +// into their command lines. Users who upgrade in place with a bare package-manager +// install (`npm install -g` or `pnpm add -g`) (instead of `ocx update`, which repairs the // shim/service) would otherwise be stranded on a dead path. Keep this stub for at // least one release cycle after the restructure ships. import "./cli/index.ts"; diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts index 091e46d3a9..5541ba7472 100644 --- a/src/cli/launcher-context.ts +++ b/src/cli/launcher-context.ts @@ -1,5 +1,5 @@ /** - * Trusted facts captured by the plain-Node npm launcher before Bun auto-loads + * Trusted facts captured by the plain-Node package launcher before Bun auto-loads * project dotenv files. The random proof travels in argv while the context * travels in the environment, so a project `.env` cannot forge the pair during * an ordinary `ocx ...` invocation. diff --git a/src/config/pending-teardown.ts b/src/config/pending-teardown.ts index 31082b0fbb..bfab1cf757 100644 --- a/src/config/pending-teardown.ts +++ b/src/config/pending-teardown.ts @@ -175,7 +175,7 @@ export function listPendingTeardowns(): OutstandingTeardown[] { } const out: OutstandingTeardown[] = []; for (const name of names) { - // One naming rule, shared with the npm launcher: the two lanes drifting apart is + // One naming rule, shared with the package launcher: the two lanes drifting apart is // exactly how the Node updater stopped seeing receipts at all. if (!isPendingTeardownFileName(name)) continue; const nonce = pendingTeardownNonceFromFileName(name)!; diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index b8a09149a8..e5c7ed31d3 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -176,7 +176,7 @@ export function durableBunRuntime(): DurableBunRuntime { /** * Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and * the Codex auto-start shim). Prefer the bundled binary — it lives under the - * npm global prefix and survives across `ocx update` — and fall back to the + * manager-owned global package directory and survives across `ocx update` — and fall back to the * current runtime, which is Bun when launched normally. */ export function durableBunPath(): string { diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index 443a164519..9b7d1e8429 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -16,7 +16,7 @@ export interface PackageTreeIntegrityGuard { } type ObservePackageTree = () => PackageTreeObservation | null; -type PackageTreeRuntimeInstall = "bun" | "npm" | "source"; +type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source"; const packageManifestUrl = new URL("../../package.json", import.meta.url); diff --git a/src/service.ts b/src/service.ts index 15a8876ba3..12d20fa0bb 100644 --- a/src/service.ts +++ b/src/service.ts @@ -71,7 +71,7 @@ const TASK = "opencodex-proxy"; export type ServiceBackend = "scheduler" | "native"; function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { - // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than + // Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts. // diff --git a/src/update/badge.ts b/src/update/badge.ts index fa4433dda9..0c373c1b71 100644 --- a/src/update/badge.ts +++ b/src/update/badge.ts @@ -1,13 +1,14 @@ /** * Cached "is an update available?" answer for the GUI sidebar badge. * - * `/api/update/check` spawns `npm view` on every call (~1s, network-bound), so a + * `/api/update/check` spawns the installing manager's `view` command on every call + * (~1s, network-bound), so a * sidebar that polls it would spawn a process per tick on every page of the GUI. * The badge instead READS the 20h version cache the CLI update prompt already * maintains (`~/.opencodex/version.json`). * * This is deliberately read-only: it must never trigger a registry refresh. The GUI - * polls it, so a refresh-on-read would let repeated polls launch repeated `npm view` + * polls it, so a refresh-on-read would let repeated polls launch repeated manager `view` * helpers with no coalescing. Cache warming stays with `ocx start` * (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit * `/api/update/check` the user reaches by clicking the sidebar update button. diff --git a/src/update/index.ts b/src/update/index.ts index 1fd5fb099c..dccc63a288 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -3,13 +3,23 @@ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs"; import { proxyIdentityAt } from "../server/proxy-liveness"; import { probeProxyLiveness } from "./proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "./stop-decision.mjs"; -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; +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 { npmInvocation } from "./npm-invocation.mjs"; +import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; +import { detectInstallFromPath } from "./install-detection.mjs"; +import { + pnpmOwnerInvocation, + readPnpmGlobalPackage, + resolvePnpmGlobalOwner, + runPnpmGlobalUpdate, +} from "./pnpm-global-install.mjs"; +import type { PnpmGlobalOwner, PnpmGlobalOwnerResult } from "./pnpm-global-install.mjs"; +import { checkRegistryPackageIntegrity } from "./registry-integrity.mjs"; import { npmCachePreflightFailureMessage, runNpmCachePreflight, @@ -35,13 +45,101 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean { export const PKG = "@bitkyc08/opencodex"; const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update -export type Installer = "bun" | "npm" | "source"; +export type Installer = "bun" | "npm" | "pnpm" | "source"; export type Channel = "latest" | "preview"; /** Infer how opencodex is installed from the running module's path. */ export function detectInstall(): Installer { - if (!HERE.includes("node_modules")) return "source"; // a git checkout, not a global install - return HERE.includes(".bun") ? "bun" : "npm"; + return detectInstallFromPath(HERE, { exists: existsSync }); +} + +function packageRoot(): string { + return resolve(HERE, "..", ".."); +} + +function runningPnpmShimPath(): string | undefined { + const invoked = process.argv[1]; + if (!invoked) return undefined; + const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase(); + if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) { + return undefined; + } + return resolve(invoked); +} + +function runPnpmCandidate( + commandPath: string, + args: readonly string[], + capture = false, +): { status: number | null; stdout?: string | null; stderr?: string | null } { + const invocation = pnpmInvocationForPath(commandPath, args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + stdio: capture ? "pipe" : "ignore", + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + ...invocation.options, + }); +} + +/** Resolve the exact pnpm executable/group/bin that own this package. */ +export function resolveCurrentPnpmGlobalOwner(): PnpmGlobalOwnerResult { + return resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath: packageRoot(), + commandPaths: resolvePnpmCommands(), + runningShimPath: runningPnpmShimPath(), + runPnpm: runPnpmCandidate, + }); +} + +function ownerPnpmTarget( + owner: PnpmGlobalOwner, + args: readonly string[], +): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean }; env: Record } | null { + const invocation = pnpmOwnerInvocation(owner, args); + if (!invocation) return null; + return { + bin: invocation.file, + args: invocation.args, + options: invocation.options, + env: invocation.env, + }; +} + +function runOwnedPnpm( + owner: PnpmGlobalOwner, + args: readonly string[], + capture: boolean, + stdio: "inherit" | "pipe" | "ignore" = capture ? "pipe" : "inherit", +): { status: number | null; stdout?: string | null; stderr?: string | null } { + const target = ownerPnpmTarget(owner, args); + if (!target) return { status: 1 }; + return spawnSync(target.bin, target.args, { + stdio, + encoding: "utf8", + timeout: 180_000, + windowsHide: true, + env: target.env, + ...target.options, + }); +} + +/** Re-read the owning group's active package and return its verified launcher. */ +export function resolvePnpmActiveLauncher(owner: PnpmGlobalOwner): string | null { + const active = readPnpmGlobalPackage( + PKG, + (args, capture = false) => runOwnedPnpm(owner, args, capture), + undefined, + { + owner, + expectedGlobalDir: owner.globalDir, + expectedGlobalRoot: owner.globalRoot, + globalBinDir: owner.globalBinDir, + }, + ); + return active.ok ? join(active.path, "bin", "ocx.mjs") : null; } export function currentVersion(): string { @@ -63,14 +161,57 @@ export function updateTag(current: string): Channel { return defaultUpdateTag(current); } -function npmSpawnTarget(args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null { +type SpawnTarget = { + bin: string; + args: string[]; + options: { windowsVerbatimArguments?: boolean }; + env?: Record; +}; + +function npmSpawnTarget(args: readonly string[]): SpawnTarget | null { const invocation = npmInvocation(args); if (!invocation) return null; return { bin: invocation.file, args: invocation.args, options: invocation.options }; } -function updateSpawnTarget(bin: string, args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null { +function pnpmSpawnTarget(args: readonly string[], owner?: PnpmGlobalOwner): SpawnTarget | null { + if (owner) { + const invocation = pnpmOwnerInvocation(owner, args); + if (!invocation) return null; + return { + bin: invocation.file, + args: invocation.args, + options: invocation.options, + env: invocation.env, + }; + } + const invocation = pnpmInvocation(args); + if (!invocation) return null; + return { bin: invocation.file, args: invocation.args, options: invocation.options }; +} + +function registrySpawnTarget( + installer: Installer, + args: readonly string[], + owner?: PnpmGlobalOwner, +): SpawnTarget | null { + // A pnpm command without an owner would silently fall back to the first PATH + // candidate. That is unsafe when multiple PNPM_HOME installations expose the + // same version, so registry queries use the same hard binding as mutation. + return installer === "pnpm" + ? owner ? pnpmSpawnTarget(args, owner) : null + : npmSpawnTarget(args); +} + +function selectedPnpmOwner(owner?: PnpmGlobalOwner): PnpmGlobalOwner | undefined { + if (owner) return owner; + const result = resolveCurrentPnpmGlobalOwner(); + return result.ok ? result.owner : undefined; +} + +function updateSpawnTarget(bin: string, args: readonly string[]): SpawnTarget | null { if (bin === "npm") return npmSpawnTarget(args); + if (bin === "pnpm") return pnpmSpawnTarget(args); if (process.platform === "win32" && bin === "bun") { return { bin: process.execPath, args: [...args], options: {} }; } @@ -95,28 +236,46 @@ function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null if (stderr) console.error(stderr.length > 4000 ? `${label}${stderr.slice(-4000)}` : stderr); } -/** Latest published version from the registry (best-effort; null if npm isn't available). */ -export function latestVersion(tag: string): string | null { - const npm = npmSpawnTarget(["view", `${PKG}@${tag}`, "version"]); - if (!npm) return null; - const r = spawnSync(npm.bin, npm.args, { +function shellQuote(value: string): string { + if (process.platform === "win32") return `"${value.replaceAll("\"", "\\\"")}"`; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function launcherStartHint(launcher: string, port: number): string { + return `${shellQuote(process.execPath)} ${shellQuote(launcher)} start --port ${Math.trunc(port)}`; +} + +/** Latest published version from the registry (best-effort; null if the manager isn't available). */ +export function latestVersion( + tag: string, + installer: Installer = detectInstall(), + owner?: PnpmGlobalOwner, +): string | null { + const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined; + if (installer === "pnpm" && !resolvedOwner) return null; + const manager = registrySpawnTarget(installer, ["view", `${PKG}@${tag}`, "version"], resolvedOwner); + if (!manager) return null; + const r = spawnSync(manager.bin, manager.args, { encoding: "utf8", timeout: 12000, windowsHide: true, - ...npm.options, + ...(manager.env ? { env: manager.env } : {}), + ...manager.options, }); - return r.status === 0 ? (r.stdout.trim() || null) : null; + return r.status === 0 && typeof r.stdout === "string" ? (r.stdout.trim() || null) : null; } /** The global-install command opencodex would run to update on this channel. */ export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } { - const bin = installer === "bun" ? "bun" : "npm"; // Immutable target: when the registry resolved a concrete version, install exactly // that version — the dist-tag can move between resolution and install (TOCTOU). const target = resolvedVersion || tag; - const args = installer === "bun" - ? ["add", "-g", `${PKG}@${target}`] - : ["install", "-g", `${PKG}@${target}`]; + if (installer === "bun") return { bin: "bun", args: ["add", "-g", `${PKG}@${target}`] }; + if (installer === "pnpm") { + return { bin: "pnpm", args: ["add", "-g", "--allow-build=bun", `${PKG}@${target}`] }; + } + const bin = "npm"; + const args = ["install", "-g", `${PKG}@${target}`]; return { bin, args }; } @@ -138,26 +297,33 @@ export function updateCommandStr(installer: Installer, tag: Channel, resolvedVer export function checkUpdatePackageIntegrity( version: string | null, spawn: typeof spawnSync = spawnSync, + installer: Installer = detectInstall(), + owner?: PnpmGlobalOwner, ): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } { - if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" }; - const npm = npmSpawnTarget(["view", `${PKG}@${version}`, "dist.integrity"]); - if (!npm) return { ok: "skipped", reason: "npm executable was not found on a trusted PATH entry" }; - const r = spawn( - npm.bin, - npm.args, - { encoding: "utf8", timeout: 12000, windowsHide: true, ...npm.options }, - ); - // status !== 0 covers nonzero exits AND timeouts (status === null). - if (r.status !== 0) return { ok: "skipped", reason: `registry integrity query failed (status ${r.status ?? "timeout"})` }; - const tokens = (r.stdout ?? "").replace(/["']/g, "").trim().split(/\s+/).filter(Boolean); - const match = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token)); - if (!match) return { ok: false, reason: `registry returned no sha512 integrity for ${PKG}@${version}` }; - return { ok: true, integrity: match }; + const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined; + if (installer === "pnpm" && !resolvedOwner) { + return { ok: false, reason: "could not identify pnpm's owning global installation" }; + } + const manager = registrySpawnTarget(installer, ["view", `${PKG}@${version}`, "dist.integrity"], resolvedOwner); + if (!manager) return { ok: "skipped", reason: `${installer} executable was not found on a trusted PATH entry` }; + const result = checkRegistryPackageIntegrity(PKG, version, args => { + const target = registrySpawnTarget(installer, args, resolvedOwner); + if (!target) return { status: 1 }; + return spawn(target.bin, target.args, { + encoding: "utf8", + timeout: 12000, + windowsHide: true, + ...(target.env ? { env: target.env } : {}), + ...target.options, + }); + }); + return result; } /** - * `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated - * in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary. + * `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 + * Bun binary. */ export async function runUpdate(): Promise { const installer = detectInstall(); @@ -170,7 +336,13 @@ export async function runUpdate(): Promise { return; } - const latest = latestVersion(tag); + const ownerResult = installer === "pnpm" ? resolveCurrentPnpmGlobalOwner() : undefined; + if (installer === "pnpm" && (!ownerResult || !ownerResult.ok)) { + console.error(`⚠️ ${ownerResult?.reason ?? "Could not identify pnpm's owning global installation"}. Aborting before stopping the proxy.`); + process.exit(1); + } + const owner = ownerResult?.ok ? ownerResult.owner : undefined; + const latest = latestVersion(tag, installer, owner); if (latest && latest === current) { console.log(`Already on the latest ${tag} version (v${latest}).`); return; @@ -178,7 +350,7 @@ export async function runUpdate(): Promise { // Pre-flight integrity metadata check — runs BEFORE the proxy is stopped so an // anomalous registry entry aborts without unloading the running service. - const integrity = checkUpdatePackageIntegrity(latest); + const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner); if (integrity.ok === false) { console.error(`⚠️ ${integrity.reason} — aborting the update before stopping the proxy.`); process.exit(1); @@ -198,9 +370,11 @@ export async function runUpdate(): Promise { } const { bin, args: cmdArgs } = updateCommand(installer, tag, latest); - const target = updateSpawnTarget(bin, cmdArgs); + const target = installer === "pnpm" && owner + ? pnpmSpawnTarget(cmdArgs, owner) + : updateSpawnTarget(bin, cmdArgs); if (!target) { - console.error("⚠️ Could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy."); + console.error(`⚠️ Could not resolve ${bin} from a trusted absolute PATH entry; aborting before stopping the proxy.`); process.exit(1); } @@ -257,7 +431,9 @@ export async function runUpdate(): Promise { // 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). // Full `ocx stop` semantics (drain, service stop, restore). + let stopAttempted = false; if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) { + stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), { @@ -266,7 +442,7 @@ export async function runUpdate(): Promise { windowsHide: true, }); if (stopStdio === "pipe") logSpawnOutput("", stop); - // One decision, shared with the npm launcher (#3008). The two lanes disagreeing about + // One decision, shared with the package launcher (#3008). The two lanes disagreeing about // the same situation is how this shipped fixed on one side only. Absent PID and runtime // files are weak evidence - a crashed-but-listening proxy leaves none - so the captured // endpoint is asked, and `null` from proxyIdentityAt covers refusal AND timeout alike. @@ -310,35 +486,96 @@ export async function runUpdate(): Promise { console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`); const installStdio = updateChildStdio(); - const r = spawnSync(target.bin, target.args, { - stdio: installStdio, - encoding: installStdio === "pipe" ? "utf8" : undefined, - timeout: 180000, - windowsHide: true, - ...target.options, - }); + // 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 postUpdateLauncherUsable = true; + let r: { + status: number | null; + signal?: NodeJS.Signals | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; + }; + if (installer === "pnpm") { + let update: ReturnType; + try { + update = runPnpmGlobalUpdate({ + packageName: PKG, + currentVersion: current, + targetVersion: latest || undefined, + tag, + owner: owner!, + runningPackagePath: packageRoot(), + runPnpm: (args, capture = false) => runOwnedPnpm( + owner!, + args, + capture, + capture ? "pipe" : installStdio, + ), + log: line => console.log(line), + }); + } catch { + // A thrown verifier/runner error means the active group is unknown. Mark the + // launcher unusable and let the failure lane report a manual recovery path. + update = { + ok: false, + phase: "rollback", + rolledBack: false, + error: "pnpm update failed unexpectedly; active package could not be verified", + }; + } + if (update.ok) { + postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); + postUpdateLauncherUsable = true; + r = { status: 0, signal: null, stdout: "", stderr: "" }; + } else { + postUpdateLauncherUsable = Boolean(update.activePath); + if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); + console.error(`⚠️ ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); + r = { status: 1, signal: null, stdout: "", stderr: "" }; + } + } else { + r = spawnSync(target.bin, target.args, { + stdio: installStdio, + encoding: installStdio === "pipe" ? "utf8" : undefined, + timeout: 180000, + windowsHide: true, + ...target.options, + }); + } if (installStdio === "pipe") logSpawnOutput("", r); if (r.status === 0) { console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`); - // Re-bake the bundled Bun path into the Codex autostart shim on every - // platform when one is installed (refresh-only; never installs fresh). + // Re-enter through the verified active package launcher. This keeps the Codex + // shim, tray, service and proxy recovery paths on the same package/group that + // pnpm selected, including when the update changed the global link target. try { - const { isCodexShimInstalled, installCodexShim } = await import("../codex/shim"); + const { isCodexShimInstalled } = await import("../codex/shim"); if (isCodexShimInstalled()) { - const result = installCodexShim(); - if (result.installed) console.log(`🔧 ${result.message}`); + const shim = spawnSync(process.execPath, [postUpdateLauncher, "codex-shim", "install"], { + stdio: "inherit", + windowsHide: true, + }); + if (shim.status !== 0) console.warn("⚠️ Shim repair skipped: run 'ocx codex-shim install'."); } - } catch (e) { - console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`); + } catch { + console.warn("⚠️ Shim repair skipped; run 'ocx codex-shim install'."); } if (trayWasInstalled) { - const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs); - const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true }); + const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs; + const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayArgs], { stdio: "inherit", windowsHide: true }); if (tray.status === 0) { console.log("🔧 Refreshed Windows tray startup paths."); } else { console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'."); - if (trayWasRunning) spawnSync(process.execPath, selfLaunchArgv(["tray", "start"]), { stdio: "ignore", windowsHide: true }); + if (trayWasRunning) spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true }); } } // The stop above unloaded any managed service; repair it with the NEW files @@ -362,7 +599,7 @@ export async function runUpdate(): Promise { process.env.OCX_BAKE_PORT = String(capturedListen.port); try { const svcStdio = updateChildStdio(); - const svc = spawnSync(process.execPath, selfLaunchArgv(serviceReinstallArgs()), { + const svc = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], { stdio: svcStdio, encoding: svcStdio === "pipe" ? "utf8" : undefined, windowsHide: true, @@ -407,7 +644,7 @@ export async function runUpdate(): Promise { : " Run 'ocx service repair' to refresh the background service and see why it failed."); const env = { ...process.env }; delete env.OCX_SERVICE; - const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(capturedListen.port)]), { + const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { detached: true, stdio: "ignore", windowsHide: true, @@ -422,14 +659,30 @@ export async function runUpdate(): Promise { else process.env.OCX_BAKE_PORT = prevBake; } } else { - console.log(`Restart the proxy: ocx start --port ${capturedListen.port}`); + console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); } } else { - if (trayWasRunning) { - try { - const { startWindowsTray } = await import("../tray/windows"); - startWindowsTray(); - } catch { /* keep the primary update failure */ } + if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) { + spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true }); + } + if (stopAttempted && serviceWasInstalled && 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) { + const env = { ...process.env }; + delete env.OCX_SERVICE; + const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: withProcessRuntimeProvenance(env), + }); + child.unref(); + } else if (stopAttempted) { + console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); } console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`); process.exit(1); diff --git a/src/update/install-detection.d.mts b/src/update/install-detection.d.mts new file mode 100644 index 0000000000..88f68ba731 --- /dev/null +++ b/src/update/install-detection.d.mts @@ -0,0 +1,6 @@ +export type DetectedInstall = "bun" | "npm" | "pnpm" | "source"; + +export declare function detectInstallFromPath( + packagePath: string, + deps?: { exists?: (path: string) => boolean; realpath?: (path: string) => string }, +): DetectedInstall; diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs new file mode 100644 index 0000000000..e21064c9b5 --- /dev/null +++ b/src/update/install-detection.mjs @@ -0,0 +1,73 @@ +import { realpathSync } from "node:fs"; + +/** + * Infer the package manager from the path of the running package. + * + * PATH is deliberately not consulted here. A machine can have npm, pnpm, and Bun + * installed at the same time; the package layout is the evidence of which manager owns + * the files that the updater must change. The legacy `global/` spelling is only + * accepted when the adjacent filesystem metadata also looks like a pnpm global group. + * + * The real path is considered in addition to the spelling visible to the module loader. + * This matters when Node is launched with preserved symlinks: a pnpm package can be + * exposed through an npm-looking prefix while its target is still under pnpm's global + * virtual store. + */ +export function detectInstallFromPath(packagePath, deps = {}) { + const exists = deps.exists; + const candidates = [String(packagePath)]; + try { + const resolved = (deps.realpath ?? realpathSync)(String(packagePath)); + if (resolved && !candidates.includes(resolved)) candidates.push(resolved); + } catch { + // Synthetic paths in source-level checks, and a partially removed install, have no + // realpath. The lexical path still carries the evidence when it is available. + } + + let sawNodeModules = false; + for (const candidate of candidates) { + const detected = detectInstallCandidate(candidate, exists); + if (detected === "pnpm" || detected === "bun") return detected; + if (detected === "npm") sawNodeModules = true; + } + return sawNodeModules ? "npm" : "source"; +} + +function detectInstallCandidate(packagePath, exists) { + const normalized = String(packagePath).replaceAll("\\", "/"); + const segments = normalized.split("/").filter(Boolean); + // Windows paths are case-insensitive. Treating the structural marker this way also + // keeps a preserved-symlink path from being downgraded merely because its casing came + // from a Windows API or a user-created junction. + if (!segments.some(segment => segment.toLowerCase() === "node_modules")) return "source"; + + // Strong signatures survive normal symlink resolution: the v10 isolated virtual store + // and v11 global virtual store are both manager-owned paths. Do not classify an arbitrary + // npm prefix such as `/opt/global/v11` from its directory name alone. + if ( + /(?:^|\/)node_modules\/\.pnpm(?:\/|$)/i.test(normalized) + || /(?:^|\/)store\/v\d+\/links(?:\/|$)/i.test(normalized) + ) return "pnpm"; + + // `--preserve-symlinks` can leave a v10/v11 group path visible. Corroborate it with the + // group's virtual store or the v11 global store before selecting pnpm. + const globalMatch = normalized.match(/^(.*\/global\/(?:v)?\d+)(?:\/[^/]+)*\/node_modules(?:\/|$)/i); + if (globalMatch && exists) { + const globalRoot = globalMatch[1]; + const groupRoot = normalized.slice(0, normalized.toLowerCase().indexOf("/node_modules")); + if ( + exists(`${groupRoot}/node_modules/.pnpm`) + || exists(`${groupRoot}/node_modules/.modules.yaml`) + || exists(`${groupRoot}/node_modules/.pnpm/lock.yaml`) + || exists(`${globalRoot}/store`) + || exists(`${globalRoot}/pnpm-lock.yaml`) + ) return "pnpm"; + } + + // Bun's global layout is specifically `.bun/install/global/node_modules`. A bare `.bun` + // directory is not enough: npm projects can quite legitimately live under a dot-directory + // with that name, especially on Windows where package paths are often user-selected. + if (/(?:^|\/)\.bun\/install\/global\/node_modules(?:\/|$)/i.test(normalized)) return "bun"; + + return "npm"; +} diff --git a/src/update/job.ts b/src/update/job.ts index 75a55b40b3..b7e51ff8c5 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -37,7 +37,10 @@ import { latestVersion, updateCommand, updateCommandStr, + resolveCurrentPnpmGlobalOwner, + resolvePnpmActiveLauncher, } from "./index"; +import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs"; import { isNewer } from "./notify"; import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; @@ -132,6 +135,10 @@ function nodeBin(): string { return process.platform === "win32" ? "node.exe" : "node"; } +function usesNodeLauncher(installer: Installer): boolean { + return installer === "npm" || installer === "pnpm"; +} + /** * Strict bind script: exit 0 only after listen+close. Any listen error (including * Windows ghost-TCB failures under Bun) is busy — matches published `ocx start` @@ -436,11 +443,13 @@ export function updateExecutionCommand( launcher = packageLauncherPath(), resolvedVersion?: string | null, ): { bin: string; args: string[]; display: string } { - if (installer === "npm") { + if (usesNodeLauncher(installer)) { const bin = nodeBin(); const args = [launcher, "update", "--tag", channel]; // The Node launcher self-update re-resolves the tag at its own time — a residual // divergence window this path cannot close (documented, not claimed immutable). + // Both npm and pnpm use it so package files are never replaced by the running Bun + // process; the launcher then selects the manager-native update implementation. return { bin, args, display: formatCommand(bin, args) }; } if (installer === "bun") { @@ -467,7 +476,7 @@ export function restartCommand( // Default to the in-place refresh: `install` always registers, while repair reuses a healthy // Windows scheduler definition and re-registers only when the live definition is stale. const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "repair"])] : startArgs; - if (installer === "npm") { + if (usesNodeLauncher(installer)) { const bin = nodeBin(); const args = svcArgs; return { mode, bin, args, display: formatCommand(bin, args) }; @@ -920,8 +929,9 @@ function spawnDetachedStart( job: UpdateJobState, installer: Installer, port?: number, + launcher = packageLauncherPath(), ): ChildProcess { - const cmd = restartCommand(false, installer, packageLauncherPath(), port); + const cmd = restartCommand(false, installer, launcher, port); const env = { ...process.env }; delete env.OCX_SERVICE; updateJob(job, {}, `$ ${cmd.display}`); @@ -961,7 +971,7 @@ function spawnDetachedStart( return child; } -/** Identity snapshot used to prove an npm self-update actually replaced the pre-update process. */ +/** Identity snapshot used to prove a package-manager self-update replaced the pre-update process. */ export interface RestartProxyIdentity { pid: number | null; version?: string; @@ -970,7 +980,9 @@ export interface RestartProxyIdentity { /** Test seam: the wait/spawn pair is injectable so the restart path is verifiable. */ export interface RestartIo { waitForPort?: typeof reclaimListenPort; - spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void; + spawnStart?: (job: UpdateJobState, installer: Installer, port?: number, launcher?: string) => void; + /** The package launcher verified after a pnpm group switch or rollback. */ + packageLauncherPathFn?: () => string; serviceInstalledFn?: () => boolean; /** * After a service reinstall exits 0, only trust the service path when this is true. @@ -1090,13 +1102,14 @@ async function restartAfterUpdate( svcArgs = serviceReinstallArgs(); } catch { /* fallback to default service install */ } } - const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs); + const launcher = io.packageLauncherPathFn?.() ?? packageLauncherPath(); + const cmd = restartCommand(serviceInstalled, job.installer, launcher, port, svcArgs); const waitFn = io.waitForPort ?? reclaimListenPort; const listPids = io.listListenPidsFn ?? listListenPids; const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity; const aliveFn = io.isAliveFn ?? isProcessAlive; // Pre-update PID plus any ocx still LISTENing on the captured port. After a - // stop-first npm self-update Windows often leaves a respawned bun/node child + // stop-first package-manager self-update Windows often leaves a respawned bun/node child // that is not the captured PID; treating it as protected blocks reclaim and // the direct-start fallback never binds. const reclaimKillAllowlist = (): number[] => { @@ -1294,7 +1307,7 @@ async function restartAfterUpdate( // Injected spawnStart keeps unit tests deterministic (one call). Production path // retries on missing /healthz after prepare + ghost-LISTEN clear. if (io.spawnStart) { - io.spawnStart(job, job.installer, port); + io.spawnStart(job, job.installer, port, launcher); return; } const sleep = io.sleepMs ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); @@ -1352,7 +1365,7 @@ async function restartAfterUpdate( ); continue; } - lastChild = spawnDetachedStart(job, job.installer, port); + lastChild = spawnDetachedStart(job, job.installer, port, launcher); const healthDeadline = Date.now() + perAttemptHealthMs; while (Date.now() < healthDeadline) { if (await probe(port, hostname)) return; @@ -1422,11 +1435,18 @@ export function restartAfterUpdateForTests( return restartAfterUpdate(job, captured, io); } -function restartFailureHint(port: number): string { +function restartFailureHint(port: number, installer: Installer): string { + const reinstall = installer === "pnpm" + ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" + : installer === "bun" + ? "bun add -g @bitkyc08/opencodex" + : installer === "source" + ? "git pull && bun install" + : "npm install -g --allow-scripts=bun @bitkyc08/opencodex"; return `Update installed, but the restarted proxy did not stay healthy on port ${port}. ` + `Try 'ocx start --port ${port}'. ` + "If the update log shows bun postinstall or EPERM warnings, " - + "reinstall with 'npm install -g --allow-scripts=bun @bitkyc08/opencodex'."; + + `reinstall with '${reinstall}'.`; } type AwaitHealthyResult = @@ -1517,7 +1537,7 @@ async function confirmRestartedProxy( status: "failed", restarted: false, error, - }, restartFailureHint(port)); + }, restartFailureHint(port, job.installer)); return false; } @@ -1559,7 +1579,7 @@ async function defaultProbeProxyIdentity( * when the pre-update PID was captured, and/or /healthz reporting the job's target * version when PID evidence is unavailable. */ -export function npmSelfUpdateRestartEvidence( +export function packageManagerSelfUpdateRestartEvidence( job: Pick, captured: { oldPid?: number }, identity: RestartProxyIdentity | null, @@ -1601,21 +1621,25 @@ export function npmSelfUpdateRestartEvidence( return { ok: false, reason: "no pre-update PID capture and no expected-version match" }; } +// Kept as a compatibility export for callers and existing integrations that used the +// original npm-specific name before pnpm became a supported package-manager path. +export const npmSelfUpdateRestartEvidence = packageManagerSelfUpdateRestartEvidence; + /** * Post-install restart for the GUI worker. * - * npm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls / + * npm and pnpm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls / * starts the service (or falls back to a direct start). A second `service install` here * calls `stopWindows()` on that healthy listener, then often fails elevation from the * non-interactive worker — leaving the captured port (default 10100) dead until a manual - * restart. Prefer confirming the npm self-update's own restart first; only re-run restart + * restart. Prefer confirming the package-manager self-update's own restart first; only re-run restart * when that probe fails. Bun/source installs still always take the explicit restart path. * - * Probe-first applies only to service-managed npm installs: without a service, `ocx.mjs` + * Probe-first applies only to service-managed npm/pnpm installs: without a service, `ocx.mjs` * only prints `ocx start` and never brings the proxy back, so waiting would always burn * the full health timeout. Skipping also requires update-correlated evidence (PID change * and/or target version) so a surviving pre-update process cannot look like success. - * After an explicit npm restart the same evidence is required again — health alone is + * After an explicit package-manager restart the same evidence is required again — health alone is * not enough when a no-op restart or failed port reclaim leaves the old proxy up. * * Browser-dashboard update recovery must not require a viable Background Service: when @@ -1628,10 +1652,10 @@ export async function finishGuiUpdateRestart( installer: Installer, io: RestartIo = {}, ): Promise { - if (installer === "npm") { + if (usesNodeLauncher(installer)) { const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)(); if (serviceInstalled) { - // Stop-first npm update leaves a dead PID's LISTEN row. Polling /healthz for the + // Stop-first package-manager update leaves a dead PID's LISTEN row. Polling /healthz for the // full 30s against that zombie keeps ESTABLISHED TCBs alive and blocks bind. // If nothing live owns the port, skip straight to explicit restart. A failed // listener scan must not look like "no listeners" — fall back to /healthz. @@ -1646,13 +1670,13 @@ export async function finishGuiUpdateRestart( ? scan.pids.filter(pid => pid !== process.pid && aliveFn(pid)) : null; if (liveListeners !== null && liveListeners.length === 0) { - updateJob(job, {}, "npm self-update did not leave a live listener; performing explicit restart..."); + updateJob(job, {}, `${installer} self-update did not leave a live listener; performing explicit restart...`); } else { if (!scan.ok) { updateJob( job, {}, - "Listener scan inconclusive after npm self-update; probing /healthz before deciding on explicit restart...", + `Listener scan inconclusive after ${installer} self-update; probing /healthz before deciding on explicit restart...`, ); } const already = await awaitRestartedProxyHealthy(job, captured, io); @@ -1661,42 +1685,42 @@ export async function finishGuiUpdateRestart( captured.port, captured.hostname, ); - const evidence = npmSelfUpdateRestartEvidence(job, captured, identity); + const evidence = packageManagerSelfUpdateRestartEvidence(job, captured, identity); if (evidence.ok) { updateJob( job, {}, - `Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`, + `Proxy already healthy on ${captured.hostname}:${captured.port} after ${installer} self-update (${evidence.detail}); skipping redundant restart.`, ); return true; } updateJob( job, {}, - `npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`, + `${installer} self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`, ); } else { - updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart..."); + updateJob(job, {}, `${installer} self-update did not leave a healthy proxy; performing explicit restart...`); } } } } const restartFn = io.restartAfterUpdateFn ?? restartAfterUpdate; await restartFn(job, captured, io); - if (installer !== "npm") { + if (!usesNodeLauncher(installer)) { // Bun/source: health alone remains enough unless a richer identity probe is supplied. if (!io.probeProxyIdentity) return confirmRestartedProxy(job, captured, io); } - return confirmNpmExplicitRestart(job, captured, io); + return confirmPackageManagerExplicitRestart(job, captured, io); } /** - * After an explicit npm (or identity-aware) restart, require update-correlated + * After an explicit package-manager (or identity-aware) restart, require update-correlated * evidence — not merely a healthy OpenCodex listener. A no-op restart or a * failed port reclaim can leave the pre-update process on the captured port; * `confirmRestartedProxy` alone would treat that as success. */ -async function confirmNpmExplicitRestart( +async function confirmPackageManagerExplicitRestart( job: UpdateJobState, captured: { port: number; hostname: string; oldPid?: number }, io: RestartIo = {}, @@ -1712,7 +1736,7 @@ async function confirmNpmExplicitRestart( status: "failed", restarted: false, error, - }, restartFailureHint(port)); + }, restartFailureHint(port, job.installer)); return false; } @@ -1720,13 +1744,13 @@ async function confirmNpmExplicitRestart( captured.port, captured.hostname, ); - const evidence = npmSelfUpdateRestartEvidence(job, captured, identity); + const evidence = packageManagerSelfUpdateRestartEvidence(job, captured, identity); if (!evidence.ok) { updateJob(job, { status: "failed", restarted: false, error: `proxy restart did not show update-correlated identity (${evidence.reason})`, - }, restartFailureHint(captured.port)); + }, restartFailureHint(captured.port, job.installer)); return false; } @@ -1748,10 +1772,16 @@ async function confirmNpmExplicitRestart( */ export interface GuiUpdateWorkerIo { cachePreflightFn?: () => { ok: boolean; reason: string }; - /** Force the resolved update target. A source checkout otherwise aborts before the npm branch. */ + /** Force the resolved update target. A source checkout otherwise aborts before the update branch. */ checkForUpdateFn?: (channel: Channel) => ReturnType; /** Bypass the registry integrity probe, which runs before the cache gate and needs network. */ integrityFn?: (version: string | null) => ReturnType; + /** Override pnpm ownership discovery for an isolated worker test. */ + resolvePnpmOwnerFn?: () => ReturnType; + /** Resolve the launcher only after the pnpm update has verified its active group and shims. */ + resolvePnpmActiveLauncherFn?: (owner: PnpmGlobalOwner) => string | null; + /** Restart seams used by focused worker tests; the verified launcher is always injected. */ + restartIo?: RestartIo; runCommandFn?: ( job: UpdateJobState, bin: string, @@ -1786,6 +1816,9 @@ export async function runGuiUpdateWorker( }; let trayWasInstalled = false; let trayWasRunning = false; + let activeLauncher = packageLauncherPath(); + let activeLauncherVerified = true; + let pnpmOwner: PnpmGlobalOwner | undefined; if (!job) { job = { id: jobId, @@ -1809,10 +1842,18 @@ export async function runGuiUpdateWorker( throw new Error(check.reason ?? "No update is available"); } + if (check.installer === "pnpm") { + const ownerResult = (io.resolvePnpmOwnerFn ?? resolveCurrentPnpmGlobalOwner)(); + if (!ownerResult.ok) throw new Error(`Could not identify pnpm's owning global installation: ${ownerResult.reason}`); + pnpmOwner = ownerResult.owner; + } + // Pre-flight integrity metadata check (same lanes as the CLI): anomalous registry // metadata for a resolved version fails the job BEFORE anything is spawned or the // proxy is stopped; transient registry failure degrades to a logged skip. - const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion); + const integrity = io.integrityFn + ? io.integrityFn(check.latestVersion) + : checkUpdatePackageIntegrity(check.latestVersion, spawnSync, check.installer, pnpmOwner); if (integrity.ok === false) { updateJob(job, { status: "failed", error: integrity.reason }); return; @@ -1865,15 +1906,19 @@ export async function runGuiUpdateWorker( /* [Decision Log] - 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다. - 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능. - - 선택 근거: 현재 CLI의 npm self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다. + - 선택 근거: 현재 CLI의 package-manager self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다. */ const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS); if (result.status !== 0) { - if (trayWasRunning) { - try { - const { startWindowsTray } = await import("../tray/windows"); - startWindowsTray(); - } catch { /* retain the primary update failure */ } + if (check.installer === "pnpm") { + const verifiedLauncher = pnpmOwner + ? (io.resolvePnpmActiveLauncherFn ?? resolvePnpmActiveLauncher)(pnpmOwner) + : null; + if (verifiedLauncher) activeLauncher = verifiedLauncher; + else activeLauncherVerified = false; + } + if (trayWasRunning && activeLauncherVerified) { + runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); } updateJob(job, { status: "failed", @@ -1884,29 +1929,36 @@ export async function runGuiUpdateWorker( return; } + if (check.installer === "pnpm") { + const verifiedLauncher = (io.resolvePnpmActiveLauncherFn ?? resolvePnpmActiveLauncher)(pnpmOwner!); + if (!verifiedLauncher) throw new Error("pnpm update succeeded but no verified active launcher remains"); + activeLauncher = verifiedLauncher; + } + if (trayWasInstalled) { - const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs); - const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000); + const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs; + const tray = runLoggedCommand(job, process.execPath, [activeLauncher, ...trayArgs], 20_000); if (tray.status !== 0) { updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'."); - if (trayWasRunning) runLoggedCommand(job, process.execPath, selfLaunchArgv(["tray", "start"]), 15_000); + if (trayWasRunning) runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); } } if (restart) { job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); - if (!(await finishGuiUpdateRestart(job, captured, check.installer))) return; + if (!(await finishGuiUpdateRestart(job, captured, check.installer, { + ...io.restartIo, + packageLauncherPathFn: () => activeLauncher, + }))) return; updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy."); return; } updateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version."); } catch (err) { - if (trayWasRunning) { - try { - const { startWindowsTray } = await import("../tray/windows"); - startWindowsTray(); - } catch { /* retain the primary worker failure */ } + if (check.installer === "pnpm") activeLauncherVerified = false; + if (trayWasRunning && activeLauncherVerified) { + runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); } updateJob(job, { status: "failed", diff --git a/src/update/pnpm-global-install.d.mts b/src/update/pnpm-global-install.d.mts new file mode 100644 index 0000000000..5dc5b6004a --- /dev/null +++ b/src/update/pnpm-global-install.d.mts @@ -0,0 +1,144 @@ +export interface PnpmRunResult { + status: number | null; + stdout?: string | Uint8Array | null; + stderr?: string | Uint8Array | null; +} + +export type RunPnpm = (args: readonly string[], capture?: boolean) => PnpmRunResult; +export type RunPnpmCandidate = ( + commandPath: string, + args: readonly string[], + capture?: boolean, +) => PnpmRunResult; + +export interface PnpmGlobalOwner { + commandPath: string; + packagePath: string; + /** Base passed to pnpm's --global-dir; pnpm creates the versioned group below it. */ + globalDir: string; + /** Actual versioned global group reported by pnpm root/list. */ + globalRoot: string; + globalBinDir: string; + version?: string; +} + +export type PnpmGlobalOwnerResult = + | { ok: true; owner: PnpmGlobalOwner } + | { ok: false; reason: string }; + +export type PnpmVerificationResult = { ok: boolean; reason?: string }; + +export declare const PNPM_BUILD_APPROVAL: string; + +export declare function pnpmGlobalCommandArgs( + args: readonly string[], + owner: PnpmGlobalOwner, +): string[]; + +export declare function pnpmOwnerEnvironment( + owner: PnpmGlobalOwner, + env?: Record, + platform?: NodeJS.Platform, +): Record; + +export interface PnpmOwnerInvocation { + file: string; + args: string[]; + options: { windowsVerbatimArguments?: boolean }; + env: Record; +} + +export declare function pnpmOwnerInvocation( + owner: PnpmGlobalOwner, + args: readonly string[], + platform?: NodeJS.Platform, + env?: Record, +): PnpmOwnerInvocation | null; + +export declare function verifyPnpmGlobalShims( + packageDir: string, + globalBinDir: string, + platform?: NodeJS.Platform, + exists?: (path: string) => boolean, +): PnpmVerificationResult; + +export type PnpmGlobalPackage = + | { + ok: true; + version: string; + path: string; + globalDir?: string; + globalRoot?: string; + globalBinDir?: string; + } + | { ok: false; reason: string }; + +export interface PnpmGlobalReadConstraints { + owner?: PnpmGlobalOwner; + expectedPackagePath?: string; + expectedGlobalDir?: string; + expectedGlobalRoot?: string; + globalBinDir?: string; + /** Set false only for pre-update owner binding; post-update reads verify shims by default. */ + checkShims?: boolean; + platform?: NodeJS.Platform; + verifyShims?: ( + packageDir: string, + globalBinDir: string, + platform?: NodeJS.Platform, + ) => PnpmVerificationResult; +} + +export declare function readPnpmGlobalPackage( + packageName: string, + runPnpm: RunPnpm, + verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult, + constraints?: PnpmGlobalReadConstraints, +): PnpmGlobalPackage; + +export declare function resolvePnpmGlobalOwner(options: { + packageName: string; + packagePath: string; + commandPaths: readonly string[]; + runningShimPath?: string; + runPnpm: RunPnpmCandidate; + verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult; + platform?: NodeJS.Platform; +}): PnpmGlobalOwnerResult; + +export type PnpmGlobalUpdateResult = + | { + ok: true; + phase: "done"; + version: string; + path: string; + globalDir: string; + globalBinDir: string; + } + | { + ok: false; + phase: "preflight" | "install" | "rollback"; + rolledBack?: boolean; + activePath?: string; + globalDir?: string; + globalBinDir?: string; + error: string; + }; + +export declare function runPnpmGlobalUpdate(options: { + packageName: string; + currentVersion?: string; + targetVersion?: string; + tag: string; + owner: PnpmGlobalOwner; + runningPackagePath?: string; + runPnpm: RunPnpm; + verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult; + verifyShims?: ( + packageDir: string, + globalBinDir: string, + platform?: NodeJS.Platform, + ) => PnpmVerificationResult; + platform?: NodeJS.Platform; + log?: (line: string) => void; +}): PnpmGlobalUpdateResult; diff --git a/src/update/pnpm-global-install.mjs b/src/update/pnpm-global-install.mjs new file mode 100644 index 0000000000..a038a374f0 --- /dev/null +++ b/src/update/pnpm-global-install.mjs @@ -0,0 +1,591 @@ +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from "node:path"; +import { pnpmInvocationForPath } from "./pnpm-invocation.mjs"; +import { verifyPnpmInstallTree } from "./transactional-install.mjs"; + +export const PNPM_BUILD_APPROVAL = "--allow-build=bun"; + +function outputText(value) { + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return new TextDecoder().decode(value); + return ""; +} + +function pathKey(value, platform = process.platform) { + const raw = String(value).replaceAll("\\", "/"); + const normalise = path => { + const result = path.replaceAll("\\", "/"); + return platform === "win32" ? result.toLowerCase() : result; + }; + try { + return normalise(realpathSync.native(raw)); + } catch { + return normalise(resolve(raw)); + } +} + +function lexicalPathKey(value, platform = process.platform) { + const result = resolve(String(value).replaceAll("\\", "/")).replaceAll("\\", "/"); + return platform === "win32" ? result.toLowerCase() : result; +} + +function samePath(left, right, platform = process.platform) { + return pathKey(left, platform) === pathKey(right, platform) + || lexicalPathKey(left, platform) === lexicalPathKey(right, platform); +} + +function singleAbsoluteCommandPath(runPnpm, commandPath, args, platform = process.platform) { + let result; + try { + result = runPnpm(commandPath, args, true); + } catch { + return null; + } + if (result?.status !== 0) return null; + const lines = outputText(result.stdout).split(/\r?\n/).map(line => line.trim()).filter(Boolean); + if (lines.length !== 1 || /^(?:undefined|null)$/i.test(lines[0])) return null; + return isAbsolutePath(lines[0], platform) ? lines[0] : null; +} + +function configPathValue(runPnpm, commandPath, key, platform = process.platform) { + return singleAbsoluteCommandPath(runPnpm, commandPath, ["config", "get", key], platform); +} + +function absoluteConfigPath(value, platform = process.platform) { + return typeof value === "string" && isAbsolutePath(value, platform) ? value : null; +} + +function isAbsolutePath(value, platform = process.platform) { + if (typeof value !== "string") return false; + return isAbsolute(value) || ( + platform === "win32" + && /^(?:[A-Za-z]:[\\/]|\\\\|\/)/.test(String(value)) + ); +} + +function pathDirname(value, platform = process.platform) { + return platform === "win32" ? win32.dirname(String(value)) : dirname(value); +} + +function pathBasename(value, platform = process.platform) { + return platform === "win32" ? win32.basename(String(value)) : basename(value); +} + +/** Normalize pnpm list/root output to the versioned global group, not its node_modules root. */ +function normaliseGlobalRoot(value, platform = process.platform) { + if (!isAbsolutePath(value, platform)) return null; + const trimmed = String(value).replace(/[\\/]+$/, ""); + return pathBasename(trimmed, platform).toLowerCase() === "node_modules" + ? pathDirname(trimmed, platform) + : trimmed; +} + +/** pnpm's --global-dir is the base; pnpm appends the major-version group below it. */ +function globalDirFromRoot(globalRoot, platform = process.platform) { + const separator = platform === "win32" ? /[\\/]/ : /\//; + const match = String(globalRoot).match(new RegExp(`^(.*)${separator.source}(?:v)?\\d+$`, "i")); + return match?.[1] || globalRoot; +} + +function globalRootMatchesDir(globalRoot, globalDir, platform = process.platform) { + return samePath(globalRoot, globalDir, platform) + || samePath(pathDirname(globalRoot, platform), globalDir, platform); +} + +/** Resolve manager-owned global paths, including pnpm defaults that config get leaves undefined. */ +function resolveGlobalPaths(commandPath, runPnpm, listedRoot, platform = process.platform) { + const listedGlobalRoot = normaliseGlobalRoot(listedRoot, platform); + const commandGlobalRoot = normaliseGlobalRoot( + singleAbsoluteCommandPath(runPnpm, commandPath, ["root", "-g"], platform), + platform, + ); + if (listedGlobalRoot && commandGlobalRoot && !samePath(listedGlobalRoot, commandGlobalRoot, platform)) { + return null; + } + const globalRoot = commandGlobalRoot ?? listedGlobalRoot; + if (!globalRoot) return null; + + const configuredGlobalDir = absoluteConfigPath(configPathValue(runPnpm, commandPath, "global-dir", platform), platform); + const globalDir = configuredGlobalDir ?? globalDirFromRoot(globalRoot, platform); + const configuredGlobalBinDir = absoluteConfigPath(configPathValue(runPnpm, commandPath, "global-bin-dir", platform), platform); + const globalBinDir = configuredGlobalBinDir + ?? absoluteConfigPath(singleAbsoluteCommandPath(runPnpm, commandPath, ["bin", "-g"], platform), platform); + if (!globalDir || !globalBinDir || !globalRootMatchesDir(globalRoot, globalDir, platform)) return null; + return { globalDir, globalRoot, globalBinDir }; +} + +function packageEntryFromRoot(root, packageName) { + const maps = [root?.dependencies, root?.devDependencies, root?.optionalDependencies]; + for (const dependencies of maps) { + if (!dependencies || typeof dependencies !== "object") continue; + const direct = dependencies[packageName]; + if (direct && typeof direct === "object") return direct; + } + return null; +} + +function inspectListOutput(stdout, packageName) { + let roots; + try { + roots = JSON.parse(outputText(stdout)); + } catch { + return null; + } + if (!Array.isArray(roots)) return null; + for (const root of roots) { + const entry = packageEntryFromRoot(root, packageName); + if (entry) return { root, entry }; + } + return null; +} + +function ownerGlobalArgs(args, owner) { + const input = [...args]; + if (!owner) return input; + const command = input[0]; + if (!["add", "install", "update", "list", "remove", "uninstall"].includes(command)) return input; + const rest = []; + for (let index = 1; index < input.length; index += 1) { + const arg = input[index]; + // The owner is authoritative. Remove an accidentally inherited/supplied value rather + // than relying on duplicate pnpm flags having stable precedence across pnpm 10/11. + if (arg === "--global-dir" || arg.startsWith("--global-dir=")) { + if (arg === "--global-dir") index += 1; + continue; + } + if (arg === "--config.global-bin-dir" || arg.startsWith("--config.global-bin-dir=")) { + if (arg === "--config.global-bin-dir") index += 1; + continue; + } + rest.push(arg); + } + return [ + command, + `--global-dir=${owner.globalDir}`, + `--config.global-bin-dir=${owner.globalBinDir}`, + ...rest, + ]; +} + +/** Add the selected pnpm global group and bin directory to a command's config. */ +export function pnpmGlobalCommandArgs(args, owner) { + return ownerGlobalArgs(args, owner); +} + +/** + * Add the owning global bin directory to PATH for commands such as pnpm's bin + * validation. Do not replace PATH: registry auth and the selected pnpm executable + * can depend on the rest of the inherited environment. + */ +export function pnpmOwnerEnvironment(owner, env = process.env, platform = process.platform) { + const key = platform === "win32" && env.Path !== undefined && env.PATH === undefined ? "Path" : "PATH"; + const delimiter = platform === "win32" ? ";" : ":"; + const existing = env[key] ?? env.PATH ?? env.Path ?? ""; + const entries = String(existing).split(delimiter).filter(Boolean); + if (!entries.some(entry => samePath(entry, owner.globalBinDir, platform))) entries.unshift(owner.globalBinDir); + return { ...env, [key]: entries.join(delimiter) }; +} + +/** Build an invocation for the already-selected pnpm executable and global group. */ +export function pnpmOwnerInvocation(owner, args, platform = process.platform, env = process.env) { + const ownerEnv = pnpmOwnerEnvironment(owner, env, platform); + const invocation = pnpmInvocationForPath( + owner.commandPath, + ownerGlobalArgs(args, owner), + platform, + ownerEnv, + ); + return invocation ? { ...invocation, env: ownerEnv } : null; +} + +function shimNames(platform) { + return platform === "win32" + ? ["ocx.cmd", "ocx.ps1", "opencodex.cmd", "opencodex.ps1"] + : ["ocx", "opencodex"]; +} + +function targetVariants(packageDir, globalBinDir, platform) { + const packageCandidates = [packageDir]; + try { packageCandidates.push(realpathSync(packageDir)); } catch { /* keep lexical path */ } + const binCandidates = [globalBinDir]; + try { binCandidates.push(realpathSync(globalBinDir)); } catch { /* keep lexical path */ } + const launcherCandidates = packageCandidates.map(candidate => join(candidate, "bin", "ocx.mjs")); + const relativeCandidates = binCandidates.flatMap(bin => launcherCandidates.map(candidate => relative(bin, candidate))); + return [...new Set([...launcherCandidates, ...relativeCandidates].map(value => { + const normalised = String(value).replaceAll("\\", "/").replace(/^\.\//, ""); + return platform === "win32" ? normalised.toLowerCase() : normalised; + }))].filter(Boolean); +} + +function shimPointsToPackage(shimPath, packageDir, globalBinDir, platform) { + try { + if (samePath(shimPath, join(packageDir, "bin", "ocx.mjs"), platform)) return true; + } catch { /* fall through to text inspection */ } + let text; + try { + text = readFileSync(shimPath, "utf8").replaceAll("\\", "/"); + if (platform === "win32") text = text.toLowerCase(); + } catch { + return false; + } + // A comment containing the new path is not a launcher. Generated cmd/PowerShell + // shims are simple enough that the target appears on an executable line; discard + // shebang/hash and REM lines before matching so a stale or hand-edited shim cannot + // pass verification by mentioning the right package in a comment. + const executableText = text.split(/\r?\n/).filter(line => { + const trimmed = line.trim(); + return !trimmed.startsWith("#") && !/^rem(?:\s|$)/i.test(trimmed); + }).join("\n"); + const expectedLauncher = join(packageDir, "bin", "ocx.mjs"); + if (shimTargetPaths(shimPath, executableText, platform).some(target => samePath(target, expectedLauncher, platform))) { + return true; + } + return targetVariants(packageDir, globalBinDir, platform).some(target => executableText.includes(target)); +} + +function shimIsRunnable(shimPath, platform) { + if (platform === "win32") return true; + try { + return (statSync(shimPath).mode & 0o111) !== 0; + } catch { + return false; + } +} + +function shimTargetPaths(shimPath, text, platform) { + const targets = []; + const launcherPattern = /((?:\$basedir(?:_win)?|\$PSScriptRoot|%~dp0|[A-Za-z]:[\\/]|\/|\.\.?[\\/])[^"'`\r\n]*[\\/]bin[\\/]ocx\.mjs)/gi; + const shimDir = pathDirname(shimPath, platform); + for (const match of text.matchAll(launcherPattern)) { + let target = match[1]; + target = target.replace(/\$basedir_win|\$basedir|\$PSScriptRoot|%~dp0/gi, shimDir); + target = target.replaceAll("\\", "/"); + targets.push(isAbsolutePath(target, platform) + ? target + : platform === "win32" ? win32.resolve(shimDir, target) : resolve(shimDir, target)); + } + return targets; +} + +/** + * Verify both generated command names and all launcher forms pnpm supports for + * the current platform. A package list/tree check is not enough: a stale shim + * can still execute the old global group after pnpm changes the active link. + */ +export function verifyPnpmGlobalShims( + packageDir, + globalBinDir, + platform = process.platform, + exists = existsSync, +) { + if (!isAbsolutePath(globalBinDir, platform)) { + return { ok: false, reason: "pnpm global bin directory is not absolute" }; + } + const missing = []; + for (const name of shimNames(platform)) { + const path = join(globalBinDir, name); + if ( + !exists(path) + || !shimIsRunnable(path, platform) + || !shimPointsToPackage(path, packageDir, globalBinDir, platform) + ) missing.push(name); + } + return missing.length === 0 + ? { ok: true } + : { ok: false, reason: `pnpm generated shim verification failed (${missing.join(", ")})` }; +} + +/** + * Read and verify the package manager's active global link. `owner` constraints + * make the listing a proof of the group selected during preflight, not merely a + * successful listing from whichever pnpm happens to be first on PATH. + */ +export function readPnpmGlobalPackage( + packageName, + runPnpm, + verify = verifyPnpmInstallTree, + constraints = {}, +) { + const platform = constraints.platform ?? process.platform; + const expectedGlobalDir = constraints.expectedGlobalDir ?? constraints.owner?.globalDir; + const expectedGlobalRoot = constraints.expectedGlobalRoot ?? constraints.owner?.globalRoot; + const globalBinDir = constraints.globalBinDir ?? constraints.owner?.globalBinDir; + let result; + try { + result = runPnpm(ownerGlobalArgs(["list", "-g", "--depth=0", "--json", packageName], constraints.owner), true); + } catch { + return { ok: false, reason: "pnpm global package listing failed" }; + } + if (result?.status !== 0) return { ok: false, reason: "pnpm global package listing failed" }; + + const inspected = inspectListOutput(result.stdout, packageName); + const entry = inspected?.entry; + const version = typeof entry?.version === "string" ? entry.version.trim() : ""; + const packagePath = typeof entry?.path === "string" ? entry.path : ""; + if (!version || !packagePath || !isAbsolutePath(packagePath, platform)) { + return { ok: false, reason: "pnpm did not report a valid active global package" }; + } + if (constraints.expectedPackagePath && !samePath(packagePath, constraints.expectedPackagePath, platform)) { + return { ok: false, reason: "pnpm active package is not the running package" }; + } + + const rootPath = normaliseGlobalRoot(inspected?.root?.path, platform); + if (expectedGlobalRoot || expectedGlobalDir) { + // pnpm versions have reported either the global group or its node_modules root; + // accept both, but require the root when the caller is proving ownership. Without + // it a successful package listing cannot distinguish a command that ignored the + // pinned group from the selected group. + if (!rootPath) return { ok: false, reason: "pnpm did not report the selected global group" }; + const rootMatches = expectedGlobalRoot + ? samePath(rootPath, expectedGlobalRoot, platform) + : globalRootMatchesDir(rootPath, expectedGlobalDir, platform); + if (!rootMatches) return { ok: false, reason: "pnpm listed a different global group" }; + } + + let tree; + try { + tree = verify(packagePath, version); + } catch { + return { ok: false, reason: "the active pnpm package could not be verified" }; + } + if (!tree?.ok) return { ok: false, reason: "the active pnpm package failed verification" }; + + // A valid package tree/group is enough to bind the owner before an update. The + // existing shim may be stale from an older pnpm run; post-update and rollback + // reads leave this enabled so a successful transaction must produce fresh shims. + if (globalBinDir && constraints.checkShims !== false) { + const shims = (constraints.verifyShims ?? verifyPnpmGlobalShims)( + packagePath, + globalBinDir, + platform, + ); + if (!shims?.ok) return { ok: false, reason: shims?.reason ?? "pnpm global shims failed verification" }; + } + return { + ok: true, + version, + path: packagePath, + globalDir: expectedGlobalDir, + globalRoot: rootPath || expectedGlobalRoot, + globalBinDir, + }; +} + +function statusText(status) { + return status === null || status === undefined ? "?" : String(status); +} + +function packageSpec(packageName, versionOrTag) { + return `${packageName}@${versionOrTag}`; +} + +function listGlobalPackage(commandPath, packageName, runPnpm) { + try { + return runPnpm(commandPath, ["list", "-g", "--depth=0", "--json", packageName], true); + } catch { + return null; + } +} + +/** + * Find the pnpm executable and global group that own the running package. Every + * candidate is inspected independently; this matters when two pnpm homes expose + * the same pnpm version but only one owns the current package/shim. + */ +export function resolvePnpmGlobalOwner({ + packageName, + packagePath, + commandPaths, + runningShimPath, + runPnpm, + verify = verifyPnpmInstallTree, + platform = process.platform, +}) { + if (!isAbsolutePath(packagePath, platform)) return { ok: false, reason: "running pnpm package path is not absolute" }; + let lastReason = "no pnpm global installation owns the running package"; + + for (const commandPath of commandPaths ?? []) { + const list = listGlobalPackage(commandPath, packageName, runPnpm); + if (list?.status !== 0) continue; + const inspected = inspectListOutput(list.stdout, packageName); + const listedPath = typeof inspected?.entry?.path === "string" ? inspected.entry.path : ""; + if (!listedPath || !isAbsolutePath(listedPath, platform) || !samePath(listedPath, packagePath, platform)) continue; + + const paths = resolveGlobalPaths(commandPath, runPnpm, inspected?.root?.path, platform); + if (!paths) { + lastReason = "pnpm owns the running package but did not report its global group and bin directory"; + continue; + } + const { globalDir, globalRoot, globalBinDir } = paths; + + // If the process was entered through a generated command shim, its directory is + // another owner fact. This disambiguates two pnpm homes that expose the same pnpm + // version and (for example after a copied prefix) report the same package path. + // Direct `node bin/ocx.mjs` and Windows shims that invoke the package path do not + // provide a usable shim path, so they continue to rely on the package/group proof. + if (runningShimPath) { + const shimName = String(runningShimPath).replaceAll("\\", "/").split("/").at(-1)?.toLowerCase(); + const isCommandShim = ["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"].includes(shimName ?? ""); + if (isCommandShim && !samePath(dirname(runningShimPath), globalBinDir, platform)) { + lastReason = "pnpm package owner did not match the running global shim"; + continue; + } + } + + const owner = { commandPath, packagePath: listedPath, globalDir, globalRoot, globalBinDir }; + const active = readPnpmGlobalPackage( + packageName, + (args, capture = false) => runPnpm(commandPath, args, capture), + verify, + { + owner, + expectedPackagePath: packagePath, + expectedGlobalDir: globalDir, + expectedGlobalRoot: globalRoot, + globalBinDir, + // Owner binding must remain possible when an older pnpm invocation left + // the top-level shim stale; the transaction verifies it after mutation. + checkShims: false, + platform, + }, + ); + if (active.ok) return { ok: true, owner: { ...owner, packagePath: active.path, version: active.version } }; + lastReason = active.reason; + } + return { ok: false, reason: lastReason }; +} + +/** + * Update a pnpm global package through pnpm itself. Never rename or remove files in + * the pnpm store. Every command is pinned to the owner discovered before the proxy + * is stopped. The pre-update proof accepts an existing stale shim; success and + * rollback require a valid tree plus fresh generated shims. + */ +export function runPnpmGlobalUpdate({ + packageName, + currentVersion, + targetVersion, + tag, + owner, + runningPackagePath, + runPnpm, + verify = verifyPnpmInstallTree, + verifyShims = verifyPnpmGlobalShims, + platform = process.platform, + log = () => {}, +}) { + if ( + !owner?.commandPath + || !owner?.globalDir + || !owner?.globalRoot + || !owner?.globalBinDir + || !isAbsolutePath(owner.commandPath, platform) + || !isAbsolutePath(owner.globalDir, platform) + || !isAbsolutePath(owner.globalBinDir, platform) + ) { + return { ok: false, phase: "preflight", error: "pnpm global owner was not pinned" }; + } + const constraints = { + owner, + expectedPackagePath: runningPackagePath ?? owner.packagePath, + expectedGlobalDir: owner.globalDir, + expectedGlobalRoot: owner.globalRoot, + globalBinDir: owner.globalBinDir, + verifyShims, + platform, + }; + const before = readPnpmGlobalPackage( + packageName, + runPnpm, + verify, + { ...constraints, checkShims: false }, + ); + if (!before.ok) return { ok: false, phase: "preflight", error: before.reason }; + if (currentVersion && before.version !== currentVersion) { + return { ok: false, phase: "preflight", error: "pnpm's active package does not match the running package" }; + } + + const requested = targetVersion || tag; + const spec = packageSpec(packageName, requested); + log(`Updating ${spec} with pnpm…`); + let install; + try { + install = runPnpm(ownerGlobalArgs(["add", "-g", PNPM_BUILD_APPROVAL, spec], owner), false); + } catch { + install = { status: 1 }; + } + + // The package path may switch to a different group link, so only constrain + // the group/bin pair after the command; the active package path is intentionally open. + const after = readPnpmGlobalPackage( + packageName, + runPnpm, + verify, + { ...constraints, expectedPackagePath: undefined }, + ); + const targetMatches = after.ok && (!targetVersion || after.version === targetVersion); + if (install?.status === 0 && targetMatches) { + return { + ok: true, + phase: "done", + version: after.version, + path: after.path, + globalDir: owner.globalDir, + globalBinDir: owner.globalBinDir, + }; + } + + const installReason = install?.status !== 0 + ? `pnpm update failed (${statusText(install?.status)})` + : after.ok + ? "pnpm update produced an unexpected active package version" + : "pnpm update completed but the active package failed verification"; + + // A failed command may have left the original active group intact. Do not create a + // second update transaction in that case; the verified previous package is already safe. + if (after.ok && after.version === before.version) { + return { + ok: false, + phase: "install", + rolledBack: true, + activePath: after.path, + globalDir: owner.globalDir, + globalBinDir: owner.globalBinDir, + error: `${installReason}; previous version remains active`, + }; + } + + log(`Restoring ${packageName}@${before.version} with pnpm…`); + let rollback; + try { + rollback = runPnpm(ownerGlobalArgs(["add", "-g", PNPM_BUILD_APPROVAL, packageSpec(packageName, before.version)], owner), false); + } catch { + rollback = { status: 1 }; + } + const restored = readPnpmGlobalPackage( + packageName, + runPnpm, + verify, + { ...constraints, expectedPackagePath: undefined }, + ); + if (rollback?.status === 0 && restored.ok && restored.version === before.version) { + return { + ok: false, + phase: "rollback", + rolledBack: true, + activePath: restored.path, + globalDir: owner.globalDir, + globalBinDir: owner.globalBinDir, + error: `${installReason}; previous version restored`, + }; + } + return { + ok: false, + phase: "rollback", + rolledBack: false, + ...(restored.ok ? { activePath: restored.path } : {}), + globalDir: owner.globalDir, + globalBinDir: owner.globalBinDir, + error: `${installReason}; previous version could not be verified after rollback`, + }; +} diff --git a/src/update/pnpm-invocation.d.mts b/src/update/pnpm-invocation.d.mts new file mode 100644 index 0000000000..4ae7a865d3 --- /dev/null +++ b/src/update/pnpm-invocation.d.mts @@ -0,0 +1,43 @@ +export interface PnpmInvocationDeps { + cwd?: string; + exists?: (path: string) => boolean; +} + +export interface PnpmInvocation { + file: string; + args: string[]; + options: { windowsVerbatimArguments?: boolean }; +} + +export declare function resolvePnpmCommands( + platform?: NodeJS.Platform, + env?: Record, + deps?: PnpmInvocationDeps, +): string[]; + +export declare function pnpmInvocationForPath( + pnpm: string, + args: readonly string[], + platform?: NodeJS.Platform, + env?: Record, +): PnpmInvocation | null; + +export declare function resolvePnpmCommand( + platform?: NodeJS.Platform, + env?: Record, + deps?: PnpmInvocationDeps, +): string | null; + +export declare function pnpmInvocation( + args: readonly string[], + platform?: NodeJS.Platform, + env?: Record, + deps?: PnpmInvocationDeps, +): PnpmInvocation | null; + +export declare function pnpmInvocations( + args: readonly string[], + platform?: NodeJS.Platform, + env?: Record, + deps?: PnpmInvocationDeps, +): PnpmInvocation[]; diff --git a/src/update/pnpm-invocation.mjs b/src/update/pnpm-invocation.mjs new file mode 100644 index 0000000000..aa5de3b699 --- /dev/null +++ b/src/update/pnpm-invocation.mjs @@ -0,0 +1,141 @@ +import { existsSync } from "node:fs"; +import { win32 } from "node:path"; + +const CMD_META = /([()%!^"`<>&|;, *?])/g; + +function escapeCmdArg(arg) { + const out = String(arg).replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\*)$/, "$1$1"); + return `"${out}"`.replace(CMD_META, "^$1"); +} + +function escapeCmdCommand(command) { + return command.replace(CMD_META, "^$1"); +} + +function cleanPathEntry(entry) { + const trimmed = entry.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1); + return trimmed; +} + +function pathEntries(platform, env) { + const raw = env.PATH ?? env.Path ?? ""; + const delimiter = platform === "win32" ? win32.delimiter : ":"; + return raw.split(delimiter).map(cleanPathEntry).filter(Boolean); +} + +function isCurrentDirectory(cwd, entry) { + const left = win32.resolve(entry); + const right = win32.resolve(cwd); + return left.toLowerCase() === right.toLowerCase(); +} + +function systemCommandProcessor(env) { + const systemRoot = env.SystemRoot ?? env.windir; + if (systemRoot && win32.isAbsolute(systemRoot)) { + return win32.join(systemRoot, "System32", "cmd.exe"); + } + const comSpec = env.ComSpec; + return comSpec && win32.isAbsolute(comSpec) ? win32.resolve(comSpec) : null; +} + +function commandPaths(platform, env, deps) { + const exists = deps.exists ?? existsSync; + const cwd = deps.cwd ?? process.cwd(); + const entries = pathEntries(platform, env); + const paths = []; + + if (platform !== "win32") { + for (const entry of entries) { + if (!entry.startsWith("/")) continue; + const candidate = `${entry}/pnpm`; + if (exists(candidate) && !paths.includes(candidate)) paths.push(candidate); + } + return paths; + } + + const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter(Boolean); + for (const entry of entries) { + if (!win32.isAbsolute(entry) || isCurrentDirectory(cwd, entry)) continue; + for (const extension of extensions) { + const candidate = win32.join(entry, `pnpm${extension.toLowerCase()}`); + if (exists(candidate)) { + const resolved = win32.resolve(candidate); + if (!paths.some(path => path.toLowerCase() === resolved.toLowerCase())) paths.push(resolved); + } + } + } + return paths; +} + +function invocationForPath(pnpm, args, platform, env) { + if (platform !== "win32" || !/\.(cmd|bat)$/i.test(pnpm)) { + return { file: pnpm, args: [...args], options: {} }; + } + + const commandProcessor = systemCommandProcessor(env); + if (!commandProcessor) return null; + const line = [escapeCmdCommand(pnpm), ...args.map(escapeCmdArg)].join(" "); + return { + file: commandProcessor, + args: ["/d", "/s", "/c", `"${line}"`], + options: { windowsVerbatimArguments: true }, + }; +} + +/** Return every absolute pnpm executable candidate in PATH, in shell order. */ +export function resolvePnpmCommands( + platform = process.platform, + env = process.env, + deps = {}, +) { + return commandPaths(platform, env, deps); +} + +/** Build an invocation for one already-selected pnpm executable. */ +export function pnpmInvocationForPath( + pnpm, + args, + platform = process.platform, + env = process.env, +) { + return invocationForPath(pnpm, args, platform, env); +} + +/** + * Resolve pnpm without relying on cmd.exe's implicit current-directory lookup on Windows. + * POSIX returns an absolute PATH candidate as well, so a service receives the same + * executable that the interactive shell selected. + */ +export function resolvePnpmCommand( + platform = process.platform, + env = process.env, + deps = {}, +) { + return resolvePnpmCommands(platform, env, deps)[0] ?? null; +} + +export function pnpmInvocation( + args, + platform = process.platform, + env = process.env, + deps = {}, +) { + const pnpm = resolvePnpmCommand(platform, env, deps); + if (!pnpm) return null; + return invocationForPath(pnpm, args, platform, env); +} + +/** Return invocations for every absolute pnpm candidate in PATH. */ +export function pnpmInvocations( + args, + platform = process.platform, + env = process.env, + deps = {}, +) { + return resolvePnpmCommands(platform, env, deps) + .map(command => invocationForPath(command, args, platform, env)) + .filter(Boolean); +} diff --git a/src/update/registry-integrity.d.mts b/src/update/registry-integrity.d.mts new file mode 100644 index 0000000000..e862672162 --- /dev/null +++ b/src/update/registry-integrity.d.mts @@ -0,0 +1,16 @@ +export interface RegistryCommandResult { + status: number | null; + stdout?: string | Uint8Array | null; + stderr?: string | Uint8Array | null; +} + +export type RegistryIntegrityResult = + | { ok: true; integrity: string } + | { ok: false; reason: string } + | { ok: "skipped"; reason: string }; + +export declare function checkRegistryPackageIntegrity( + packageName: string, + version: string | null | undefined, + run: (args: readonly string[], capture?: boolean) => RegistryCommandResult, +): RegistryIntegrityResult; diff --git a/src/update/registry-integrity.mjs b/src/update/registry-integrity.mjs new file mode 100644 index 0000000000..3c0f5f3893 --- /dev/null +++ b/src/update/registry-integrity.mjs @@ -0,0 +1,37 @@ +/** + * Shared registry metadata pre-flight for every direct package-manager launcher. + * + * This intentionally does not perform the query itself. The caller supplies the + * already-hardened npm/pnpm invocation, so the plain Node launcher and the Bun + * update worker apply exactly the same integrity policy without importing TypeScript + * into the published launcher. + */ +function outputText(value) { + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return new TextDecoder().decode(value); + return ""; +} + +/** + * Check the registry's dist.integrity value for one immutable package version. + * A failed query is a best-effort skip; successful metadata without a sha512 SRI + * value is anomalous and fails closed before the caller changes local state. + */ +export function checkRegistryPackageIntegrity(packageName, version, run) { + if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" }; + + let result; + try { + result = run(["view", `${packageName}@${version}`, "dist.integrity"], true); + } catch { + return { ok: "skipped", reason: "registry integrity query failed" }; + } + if (result?.status !== 0) { + return { ok: "skipped", reason: `registry integrity query failed (status ${result?.status ?? "timeout"})` }; + } + + const tokens = outputText(result.stdout).replace(/["']/g, "").trim().split(/\s+/).filter(Boolean); + const integrity = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token)); + if (!integrity) return { ok: false, reason: `registry returned no sha512 integrity for ${packageName}@${version}` }; + return { ok: true, integrity }; +} diff --git a/src/update/transactional-install.d.mts b/src/update/transactional-install.d.mts index 398639e4f9..11d1c1970e 100644 --- a/src/update/transactional-install.d.mts +++ b/src/update/transactional-install.d.mts @@ -1,5 +1,6 @@ export type InstallTreeVerification = { ok: boolean; failures: string[] }; export function verifyInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification; +export function verifyPnpmInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification; export function bootRestoreProbe( packageDir: string, deps?: { rename?: (from: string, to: string) => void }, @@ -19,4 +20,3 @@ export function transactionalNpmUpdate(args: { rolledBack?: boolean; backup?: string; }; - diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs index 9b61c9fe33..d08cc4ebb3 100644 --- a/src/update/transactional-install.mjs +++ b/src/update/transactional-install.mjs @@ -17,11 +17,77 @@ * /.ocx-recovery.json double-fault marker with a one-line restore */ import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; + +/** + * Dependency lookup confined to the candidate's OWN tree. This is the npm contract and it + * must stay lexical: Node's resolver walks the ancestor directory chain, so a global npm + * candidate at /lib/node_modules/@scope/pkg could satisfy its bundled-Bun + * requirement from /lib/node_modules/bun, which belongs to a different package. + * That verdict is not cosmetic — it accepts a stage that cannot start (D2), skips the + * post-swap rollback, and lets bootRestoreProbe reap the only known-good backup. + */ +function candidateTreeDependencyDir(packageDir, name) { + const dir = join(packageDir, "node_modules", ...name.split("/")); + return existsSync(join(dir, "package.json")) ? dir : undefined; +} + +/** The node_modules directory a package sits directly inside, or undefined. */ +function enclosingNodeModules(packageDir) { + const parent = dirname(packageDir); + if (basename(parent) === "node_modules") return parent; + // Scoped packages live one level deeper: /@scope/name. + const grandparent = dirname(parent); + if (basename(parent).startsWith("@") && basename(grandparent) === "node_modules") return grandparent; + return undefined; +} + +/** pnpm's own bookkeeping at the root of a node_modules tree it manages. */ +function isPnpmManagedRoot(nodeModulesDir) { + if (!nodeModulesDir) return false; + if (nodeModulesDir.split(/[\\/]/).includes(".pnpm")) return true; + return existsSync(join(nodeModulesDir, ".pnpm")) || existsSync(join(nodeModulesDir, ".modules.yaml")); +} + +/** + * Dependency roots this package INSTANCE owns. pnpm exposes dependencies in several shapes — + * symlinks inside the package's own node_modules, a package root that is itself a symlink into + * the virtual store, or a hoisted group root — so the npm rule alone rejects healthy trees. + * Ownership is still bounded: an enclosing node_modules counts only when pnpm's own metadata + * says pnpm manages it, which keeps an unrelated ancestor installation out. + */ +function ownedDependencyRoots(packageDir) { + const roots = []; + const add = dir => { if (dir && !roots.includes(dir)) roots.push(dir); }; + const lexicalGroup = enclosingNodeModules(packageDir); + add(join(packageDir, "node_modules")); + let real; + try { real = realpathSync(packageDir); } catch { /* keep the lexical path only */ } + if (real && real !== packageDir) add(join(real, "node_modules")); + if (isPnpmManagedRoot(lexicalGroup)) add(lexicalGroup); + const realGroup = real ? enclosingNodeModules(real) : undefined; + if (isPnpmManagedRoot(realGroup)) add(realGroup); + return roots; +} + +/** + * pnpm dependency lookup. Probing the owned roots directly, rather than filtering whatever + * Node's resolver returned, is deliberate: require.resolve reports the REALPATH of the + * resolved file, so a dependency reached through pnpm's own node_modules symlink comes back + * as a virtual-store path that no lexical ownership test can recognise. existsSync follows + * the symlink, which is exactly the pnpm graph edge that proves ownership. + */ +function pnpmOwnedDependencyDir(packageDir, name) { + for (const root of ownedDependencyRoots(packageDir)) { + const dir = join(root, ...name.split("/")); + if (existsSync(join(dir, "package.json"))) return dir; + } + return undefined; +} /** Verification manifest for a staged (or live) package tree. */ -export function verifyInstallTree(packageDir, expectedVersion) { +function verifyTreeWithDependencyLookup(packageDir, expectedVersion, dependencyDir) { const failures = []; let pkg; try { @@ -42,8 +108,8 @@ export function verifyInstallTree(packageDir, expectedVersion) { // The bundled Bun binary is the load-bearing artifact: without it the launcher exits // before serving anything, and a boot probe that called this tree healthy would reap // the only backup (review High 3). Size-gate the real binary, not just its package.json. - const bunPkgDir = join(packageDir, "node_modules", "bun"); - if (existsSync(bunPkgDir)) { + const bunPkgDir = dependencyDir(packageDir, "bun"); + if (bunPkgDir) { const bunBinary = findLargestFile(bunPkgDir); if (!bunBinary || bunBinary.size < 10 * 1024 * 1024) { failures.push("bundled Bun binary missing or truncated (< 10MB)"); @@ -56,12 +122,29 @@ export function verifyInstallTree(packageDir, expectedVersion) { ? deps.filter(name => name === "bun" || name === "zod") : deps.slice(0, 2); for (const name of sentinels) { - const depPkg = join(packageDir, "node_modules", ...name.split("/"), "package.json"); - if (!existsSync(depPkg)) failures.push("sentinel dependency missing: " + name); + if (!dependencyDir(packageDir, name)) failures.push("sentinel dependency missing: " + name); } return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures }; } +/** + * npm (and every recovery decision): the candidate must be self-contained. Used by + * transactionalNpmUpdate's stage and post-swap checks and by bootRestoreProbe. + */ +export function verifyInstallTree(packageDir, expectedVersion) { + return verifyTreeWithDependencyLookup(packageDir, expectedVersion, candidateTreeDependencyDir); +} + +/** + * Verify a package exposed through pnpm's global virtual store. pnpm 10/11 may use an + * isolated virtual store, a custom virtualStoreDir, global virtual-store links, or a + * hoisted linker, so the dependency may sit outside the package directory — but it must + * still be reachable through a root this package instance owns. + */ +export function verifyPnpmInstallTree(packageDir, expectedVersion) { + return verifyTreeWithDependencyLookup(packageDir, expectedVersion, pnpmOwnedDependencyDir); +} + function stampedName(prefix) { return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-"); } diff --git a/src/update/tray-update-plan.mjs b/src/update/tray-update-plan.mjs index 695a3467aa..606a5d254c 100644 --- a/src/update/tray-update-plan.mjs +++ b/src/update/tray-update-plan.mjs @@ -1,6 +1,6 @@ /** * Shared, side-effect-free contract for preserving the Windows tray across all - * updater entry points (npm launcher, CLI updater, and GUI worker). + * updater entry points (package launcher, CLI updater, and GUI worker). */ export function planWindowsTrayUpdate(status) { const installed = status?.installed === true; diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index f6c50559f5..fc41950115 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -181,15 +181,17 @@ exit 0 }, ); - test("Node launcher handles npm self-update before starting Bun", async () => { + test("Node launcher handles package-manager self-update before starting Bun", async () => { const launcher = await readText("bin/ocx.mjs"); expect(launcher).toContain('process.argv[2] === "update"'); expect(launcher).toContain('["install", "-g", `${PKG}@${tag}`]'); + expect(launcher).toContain('["add", "-g", "--allow-build=bun", `${PKG}@${tag}`]'); expect(launcher).toContain('return String(currentVersion).includes("-preview.") ? "preview" : "latest"'); expect(launcher).toContain("!isBunGlobalInstall()"); - expect(launcher).toContain("repairCodexShimIfNeeded()"); + expect(launcher).toContain("repairCodexShimIfNeeded(postUpdateLauncher)"); expect(launcher).toContain("runNpmSelfUpdate()"); + expect(launcher).toContain("runPnpmSelfUpdate()"); }); test("release helper watches the workflow run it just dispatched", async () => { diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index b6064d2284..b6edb5b412 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -311,7 +311,7 @@ function isolatedLauncherEnv(root: string, override: string): NodeJS.ProcessEnv }; } -describe.skipIf(!nodeAvailable)("ocx npm launcher relative Bun override", () => { +describe.skipIf(!nodeAvailable)("ocx package launcher relative Bun override", () => { test("resolves a valid bare relative override before spawning", () => { const root = mkdtempSync(join(tmpdir(), "ocx-launcher-relative-")); try { @@ -358,7 +358,7 @@ describe.skipIf(!nodeAvailable)("ocx npm launcher relative Bun override", () => }, 60_000); }); -describe.skipIf(!runnable)("ocx npm launcher effective Bun runtime", () => { +describe.skipIf(!runnable)("ocx package launcher effective Bun runtime", () => { test("uses a valid OPENCODEX_BUN_PATH for the actual proxy process", async () => { const root = mkdtempSync(join(tmpdir(), "ocx-launcher-runtime-copy-")); try { diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index 169646aeba..f15293f48a 100644 --- a/tests/cli/ocx-launcher-source.test.ts +++ b/tests/cli/ocx-launcher-source.test.ts @@ -14,7 +14,7 @@ const validatorSource = readFileSync( "utf8", ); -describe("ocx.mjs npm launcher (source invariants)", () => { +describe("ocx.mjs package launcher (source invariants)", () => { test("the Bun child receives the runtime provenance the launcher actually selected (#848)", () => { // The launcher is a plain-Node bin script executing at import time, so this is // asserted at the source level: the marker must reach the spawn env, and it must @@ -58,9 +58,12 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(spawnCall).toContain("windowsHide: true"); }); - test("Windows npm spawns use the trusted absolute invocation without shell lookup", () => { - expect(source).toContain("const latestInvocation = npmInvocation("); - expect(source).toContain("const installInvocation = npmInvocation("); + test("Windows package-manager spawns use the trusted absolute invocation without shell lookup", () => { + expect(source).toContain("resolvePnpmGlobalOwner"); + expect(source).toContain("const managerInvocation = args => manager === \"pnpm\""); + expect(source).toContain("pnpmOwnerInvocation(owner, args)"); + expect(source).toContain("const latestInvocation = managerInvocation("); + expect(source).toContain("const installInvocation = managerInvocation(installArgs);"); expect(source).toContain("spawnSync(latestInvocation.file, latestInvocation.args"); // #1942: the staged install spawns through the same hardened npmInvocation resolver // inside the transactional runNpm callback. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..a9784f67e1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1067,10 +1067,12 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", "update-transactional.test.ts": "update", "update-tray-handoff.test.ts": "update", + "update-tree-ownership.test.ts": "update", "upstream-connect-error.test.ts": "server", "upstream-http-error.test.ts": "adapters", "upstream-http-version.test.ts": "server", diff --git a/tests/update/update-badge.test.ts b/tests/update/update-badge.test.ts index b290b7615c..c961fc466f 100644 --- a/tests/update/update-badge.test.ts +++ b/tests/update/update-badge.test.ts @@ -57,7 +57,7 @@ describe("readUpdateBadge", () => { test("reading the badge never spawns a registry refresh", () => { // The GUI polls this endpoint. A refresh-on-read would let repeated polls launch - // repeated `npm view` helpers with no coalescing, so the deps surface has no + // repeated manager `view` helpers with no coalescing, so the deps surface has no // refresh hook at all — this test pins that shape. const keys = Object.keys(deps({})); expect(keys).toEqual(["currentVersion", "detectInstall", "readCache"]); diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts index 6e02ecd575..9ba42b8150 100644 --- a/tests/update/update-job.test.ts +++ b/tests/update/update-job.test.ts @@ -436,6 +436,55 @@ describe("GUI update execution decisions", () => { expect(cmd.args).toEqual(["/pkg/bin/ocx.mjs", "update", "--tag", "preview"]); }); + test("pnpm worker uses the Node launcher update path", () => { + const cmd = updateExecutionCommand("pnpm", "latest", "/pkg/bin/ocx.mjs"); + expect(cmd.bin).toMatch(/^node/); + expect(cmd.args).toEqual(["/pkg/bin/ocx.mjs", "update", "--tag", "latest"]); + }); + + test("pnpm GUI worker passes the verified active launcher into restart recovery", async () => { + const activeLauncher = "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs"; + let restartLauncher = ""; + let now = 0; + await runGuiUpdateWorker("pnpm-active-launcher", "latest", true, { + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "pnpm", + updateAvailable: true, + canUpdate: true, + command: "node /old/bin/ocx.mjs update --tag latest", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + resolvePnpmOwnerFn: () => ({ + ok: true as const, + owner: { + commandPath: "/pnpm/owner/bin/pnpm", + packagePath: "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex", + globalDir: "/pnpm/owner/global", + globalRoot: "/pnpm/owner/global/v11", + globalBinDir: "/pnpm/owner/bin", + }, + }), + resolvePnpmActiveLauncherFn: () => activeLauncher, + integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), + runCommandFn: () => ({ status: 0, signal: null }), + restartIo: { + serviceInstalledFn: () => false, + restartAfterUpdateFn: async (_job, _captured, io) => { + restartLauncher = io?.packageLauncherPathFn?.() ?? ""; + }, + probeProxy: async () => true, + probeProxyIdentity: async () => ({ pid: 4242, version: "2.7.41" }), + now: () => now, + sleepMs: async ms => { now += ms; }, + }, + }); + expect(restartLauncher).toBe(activeLauncher); + expect(readUpdateJob("pnpm-active-launcher")?.status).toBe("succeeded"); + }); + test("restart command separates service and direct proxy modes", () => { expect(restartCommand(true, "npm", "/pkg/bin/ocx.mjs")).toMatchObject({ mode: "service", @@ -445,6 +494,55 @@ describe("GUI update execution decisions", () => { mode: "proxy", args: ["/pkg/bin/ocx.mjs", "start"], }); + expect(restartCommand(true, "pnpm", "/pkg/bin/ocx.mjs")).toMatchObject({ + mode: "service", + args: ["/pkg/bin/ocx.mjs", "service", "repair"], + }); + }); + + test("restart recovery uses the verified active launcher for direct and service paths", async () => { + const activeLauncher = "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs"; + const directJob: UpdateJobState = { + id: "restart-active-launcher-direct", + status: "restarting", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "pnpm", + restart: true, + command: "", + log: [], + }; + writeFileSync(updateJobPath(directJob.id), JSON.stringify(directJob)); + let directLauncher = ""; + await restartAfterUpdateForTests(directJob, { port: 19001, hostname: "127.0.0.1" }, { + serviceInstalledFn: () => false, + packageLauncherPathFn: () => activeLauncher, + listListenPidsFn: () => [], + waitForPort: async () => true, + spawnStart: (_job, _installer, _port, launcher) => { directLauncher = launcher ?? ""; }, + }); + expect(directLauncher).toBe(activeLauncher); + + const serviceJob = { ...directJob, id: "restart-active-launcher-service" }; + writeFileSync(updateJobPath(serviceJob.id), JSON.stringify(serviceJob)); + let serviceArgs: string[] = []; + await restartAfterUpdateForTests(serviceJob, { port: 19002, hostname: "127.0.0.1" }, { + serviceInstalledFn: () => true, + packageLauncherPathFn: () => activeLauncher, + listListenPidsFn: () => [], + waitForPort: async () => true, + runService: (_job, _bin, args) => { + serviceArgs = args; + return { status: 0 }; + }, + serviceViableFn: () => true, + probeProxy: async () => true, + serviceHealthTimeoutMs: 1_000, + }); + expect(serviceArgs).toEqual([activeLauncher, "service", "repair"]); }); test("service restart is not skipped when the listener scan fails", async () => { @@ -1605,7 +1703,7 @@ describe("immutable update target (WP160)", () => { test("GUI worker gates integrity before spawning and fails the job on anomalous metadata", async () => { const source = await Bun.file(new URL("../../src/update/job.ts", import.meta.url)).text(); - const gateAt = source.indexOf("const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion);"); + const gateAt = source.indexOf("checkUpdatePackageIntegrity(check.latestVersion, spawnSync, check.installer, pnpmOwner)"); const cacheGateAt = source.indexOf("const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();"); const trayStopAt = source.indexOf("handoffWindowsTrayForUpdate(tray"); const failAt = source.indexOf('updateJob(job, { status: "failed", error: integrity.reason });'); diff --git a/tests/update/update-pnpm.test.ts b/tests/update/update-pnpm.test.ts new file mode 100644 index 0000000000..aadf1ce960 --- /dev/null +++ b/tests/update/update-pnpm.test.ts @@ -0,0 +1,620 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + detectInstallFromPath, +} from "../../src/update/install-detection.mjs"; +import { + pnpmInvocation, + pnpmInvocations, + resolvePnpmCommand, + resolvePnpmCommands, +} from "../../src/update/pnpm-invocation.mjs"; +import { + pnpmGlobalCommandArgs, + pnpmOwnerEnvironment, + readPnpmGlobalPackage, + resolvePnpmGlobalOwner, + runPnpmGlobalUpdate, + verifyPnpmGlobalShims, + type PnpmGlobalOwner, + type PnpmRunResult, +} from "../../src/update/pnpm-global-install.mjs"; +import { checkRegistryPackageIntegrity } from "../../src/update/registry-integrity.mjs"; +import { verifyPnpmInstallTree } from "../../src/update/transactional-install.mjs"; +import { updateCommand, updateCommandStr } from "../../src/update/index"; + +const PKG = "@bitkyc08/opencodex"; + +describe("pnpm installation detection", () => { + test("requires strong evidence for legacy global/vN paths", () => { + expect(detectInstallFromPath("/work/opencodex/src/update")).toBe("source"); + expect(detectInstallFromPath("/usr/lib/node_modules/@bitkyc08/opencodex/bin")).toBe("npm"); + expect(detectInstallFromPath("/tmp/test-user/.bun/install/global/node_modules/@bitkyc08/opencodex/bin")).toBe("bun"); + expect(detectInstallFromPath("/tmp/test-user/.bun/node_modules/@bitkyc08/opencodex/bin")).toBe("npm"); + expect(detectInstallFromPath("/opt/global/v11/node_modules/@bitkyc08/opencodex/bin")).toBe("npm"); + expect(detectInstallFromPath("/opt/pnpm/global/v11/node_modules/@bitkyc08/opencodex/bin", { + exists: path => path === "/opt/pnpm/global/v11/node_modules/.pnpm", + })).toBe("pnpm"); + }); + + test("recognises isolated, store-link, and preserved-symlink layouts", () => { + expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/global/v11/node_modules/.pnpm/@bitkyc08+opencodex@2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm"); + expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/store/v11/links/@bitkyc08/opencodex/2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm"); + expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/global/11/group/node_modules/@bitkyc08/opencodex/bin", { + exists: path => path === "/tmp/test-user/.local/share/pnpm/global/11/group/node_modules/.pnpm", + })).toBe("pnpm"); + expect(detectInstallFromPath("C:\\work\\node_modules\\.pnpm\\@bitkyc08+opencodex@2.49.0\\node_modules\\@bitkyc08\\opencodex\\bin")).toBe("pnpm"); + }); + + test("follows a preserved npm-looking symlink to the pnpm package target", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-detect-link-")); + try { + const target = join(root, "pnpm", "global", "v11", "node_modules", ".pnpm", "pkg", "node_modules", PKG, "bin"); + const exposed = join(root, "prefix", "node_modules", PKG, "bin"); + mkdirSync(target, { recursive: true }); + mkdirSync(dirname(exposed), { recursive: true }); + symlinkSync(target, exposed, "dir"); + expect(detectInstallFromPath(exposed)).toBe("pnpm"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("recognises pnpm metadata for a hoisted group without a virtual-store directory", () => { + const path = "/opt/pnpm/global/v11/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallFromPath(path, { + exists: candidate => candidate === "/opt/pnpm/global/v11/node_modules/.modules.yaml", + })).toBe("pnpm"); + }); +}); + +describe("pnpm executable selection", () => { + test("retains every absolute candidate so ownership can be matched", () => { + const env = { PATH: "/first:/second:/third" }; + const existing = new Set(["/first/pnpm", "/second/pnpm"]); + expect(resolvePnpmCommands("linux", env, { exists: path => existing.has(path) })).toEqual([ + "/first/pnpm", "/second/pnpm", + ]); + expect(pnpmInvocations(["--version"], "linux", env, { exists: path => existing.has(path) })).toHaveLength(2); + }); + + test("uses the trusted Windows pnpm shim through cmd.exe", () => { + const cwd = "C:\\work\\untrusted-project"; + const trustedPnpm = "C:\\Program Files\\pnpm\\pnpm.cmd"; + const systemCmd = "C:\\Windows\\System32\\cmd.exe"; + const env = { + PATH: `${cwd};C:\\Program Files\\pnpm`, + PATHEXT: ".CMD", + SystemRoot: "C:\\Windows", + }; + const existing = new Set([`${cwd}\\pnpm.cmd`, trustedPnpm]); + + expect(resolvePnpmCommand("win32", env, { + cwd, + exists: path => existing.has(path), + })).toBe(trustedPnpm); + + const invocation = pnpmInvocation(["add", "-g", "--allow-build=bun", `${PKG}@2.50.0`], "win32", env, { + cwd, + exists: path => existing.has(path), + }); + expect(invocation).toMatchObject({ + file: systemCmd, + args: ["/d", "/s", "/c", expect.stringContaining("pnpm\\pnpm.cmd")], + options: { windowsVerbatimArguments: true }, + }); + expect(String(invocation?.args.at(-1) ?? "").includes(cwd)).toBe(false); + }); +}); + +const ownerFor = (version = "1.0.0"): PnpmGlobalOwner => ({ + commandPath: "/pnpm/owner/bin/pnpm", + packagePath: `/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex-${version}`, + globalDir: "/pnpm/owner/global", + globalRoot: "/pnpm/owner/global/v11", + globalBinDir: "/pnpm/owner/bin", +}); + +describe("pnpm global owner binding", () => { + test("selects the candidate whose global listing owns the running package", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-owner-")); + const packagePath = join(root, "home-b", "global", "v11", "node_modules", "@bitkyc08", "opencodex"); + const groupB = join(root, "home-b", "global", "v11"); + const baseB = join(root, "home-b", "global"); + const binB = join(root, "home-b", "bin"); + mkdirSync(packagePath, { recursive: true }); + try { + const calls: { command: string; args: string[] }[] = []; + const run = (command: string, args: readonly string[], capture = false): PnpmRunResult => { + calls.push({ command, args: [...args] }); + if (args[0] === "list") { + const listed = command === "/pnpm/a/bin/pnpm" + ? join(root, "home-a", "global", "v11", "node_modules", "@bitkyc08", "opencodex") + : packagePath; + const group = command === "/pnpm/a/bin/pnpm" ? join(root, "home-a", "global", "v11") : groupB; + return { + status: 0, + stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: listed } } }]), + }; + } + if (args[0] === "root") { + return { status: 0, stdout: `${groupB}\n` }; + } + if (args[0] === "config" && args[2] === "global-dir") { + return { status: 0, stdout: `${baseB}\n` }; + } + if (args[0] === "config" && args[2] === "global-bin-dir") { + return { status: 0, stdout: `${binB}\n` }; + } + return { status: 1 }; + }; + + const result = resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath, + commandPaths: ["/pnpm/a/bin/pnpm", "/pnpm/b/bin/pnpm"], + runPnpm: run, + verify: () => ({ ok: true }), + }); + + expect(result).toEqual({ + ok: true, + owner: { + commandPath: "/pnpm/b/bin/pnpm", + packagePath, + version: "1.0.0", + globalDir: baseB, + globalRoot: groupB, + globalBinDir: binB, + }, + }); + expect(calls.some(call => call.command === "/pnpm/a/bin/pnpm")).toBe(true); + expect(calls.filter(call => call.command === "/pnpm/b/bin/pnpm" && call.args[0] === "list").at(-1)?.args).toContain(`--global-dir=${baseB}`); + expect(calls.filter(call => call.command === "/pnpm/b/bin/pnpm" && call.args[0] === "list").at(-1)?.args).toContain(`--config.global-bin-dir=${binB}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses the running shim to disambiguate same-version pnpm homes", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shim-owner-")); + const packagePath = join(root, "shared", "node_modules", PKG); + const groupA = join(root, "home-a", "global", "v11"); + const groupB = join(root, "home-b", "global", "v11"); + const baseA = join(root, "home-a", "global"); + const baseB = join(root, "home-b", "global"); + const binA = join(root, "home-a", "bin"); + const binB = join(root, "home-b", "bin"); + const runningShim = join(binB, "ocx"); + mkdirSync(packagePath, { recursive: true }); + try { + const run = (command: string, args: readonly string[]): PnpmRunResult => { + if (args[0] === "list") { + const group = command === "/pnpm/a" ? groupA : groupB; + return { + status: 0, + stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: packagePath } } }]), + }; + } + if (args[0] === "root") { + return { status: 0, stdout: `${command === "/pnpm/a" ? groupA : groupB}\n` }; + } + if (args[0] === "config" && args[2] === "global-dir") { + return { status: 0, stdout: `${command === "/pnpm/a" ? baseA : baseB}\n` }; + } + if (args[0] === "config" && args[2] === "global-bin-dir") { + return { status: 0, stdout: `${command === "/pnpm/a" ? binA : binB}\n` }; + } + return { status: 1 }; + }; + const result = resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath, + commandPaths: ["/pnpm/a", "/pnpm/b"], + runningShimPath: runningShim, + runPnpm: run, + verify: () => ({ ok: true }), + }); + expect(result).toMatchObject({ ok: true, owner: { commandPath: "/pnpm/b", globalDir: baseB, globalRoot: groupB, globalBinDir: binB } }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("derives pnpm's default global-dir base when config get is undefined", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-default-owner-")); + const base = join(root, "global"); + const group = join(base, "v11"); + const bin = join(root, "bin"); + const packagePath = join(group, "node_modules", PKG); + mkdirSync(packagePath, { recursive: true }); + try { + const calls: string[][] = []; + const run = (_command: string, args: readonly string[]): PnpmRunResult => { + calls.push([...args]); + if (args[0] === "list") { + return { + status: 0, + stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: packagePath } } }]), + }; + } + if (args[0] === "root") return { status: 0, stdout: `${group}\n` }; + if (args[0] === "bin") return { status: 0, stdout: `${bin}\n` }; + if (args[0] === "config" && args[2] !== undefined) return { status: 0, stdout: "undefined\n" }; + return { status: 1 }; + }; + const result = resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath, + commandPaths: ["/pnpm/default"], + runPnpm: run, + verify: () => ({ ok: true }), + }); + + expect(result).toEqual({ + ok: true, + owner: { commandPath: "/pnpm/default", packagePath, version: "1.0.0", globalDir: base, globalRoot: group, globalBinDir: bin }, + }); + const pinnedList = calls.find(args => args[0] === "list" && args.includes(`--global-dir=${base}`)); + expect(pinnedList).toBeDefined(); + expect(pinnedList).not.toContain(`--global-dir=${group}`); + expect(pinnedList).not.toContain(`${group}/v11`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("pins both global group and bin directory and preserves the selected PATH", () => { + const owner = ownerFor(); + expect(pnpmGlobalCommandArgs(["add", "-g", "--allow-build=bun", `${PKG}@2.0.0`], owner)).toEqual([ + "add", + `--global-dir=${owner.globalDir}`, + `--config.global-bin-dir=${owner.globalBinDir}`, + "-g", + "--allow-build=bun", + `${PKG}@2.0.0`, + ]); + const env = pnpmOwnerEnvironment(owner, { PATH: "/usr/bin" }, "linux"); + expect(env.PATH).toBe(`${owner.globalBinDir}:/usr/bin`); + expect(pnpmGlobalCommandArgs([ + "list", "-g", `--global-dir=${owner.globalDir}/wrong`, "--config.global-bin-dir", "/wrong/bin", PKG, + ], owner)).toEqual([ + "list", + `--global-dir=${owner.globalDir}`, + `--config.global-bin-dir=${owner.globalBinDir}`, + "-g", PKG, + ]); + }); + + test("rejects a pinned verification when pnpm omits the group root", () => { + const owner = ownerFor(); + const result = readPnpmGlobalPackage( + PKG, + () => ({ + status: 0, + stdout: JSON.stringify([{ dependencies: { [PKG]: { version: "1.0.0", path: owner.packagePath } } }]), + }), + () => ({ ok: true }), + { owner, expectedGlobalDir: owner.globalDir, globalBinDir: owner.globalBinDir, verifyShims: () => ({ ok: true }) }, + ); + expect(result).toEqual({ ok: false, reason: "pnpm did not report the selected global group" }); + }); +}); + +function makePackageFixture( + root: string, + dependencyRoot: string, + options: { packageDir?: string; linkDependenciesInside?: boolean } = {}, +) { + const packageDir = options.packageDir ?? join(root, "package"); + const bunDir = join(dependencyRoot, "bun"); + const zodDir = join(dependencyRoot, "zod"); + mkdirSync(join(packageDir, "bin", "nested"), { recursive: true }); + mkdirSync(join(packageDir, "node_modules"), { recursive: true }); + mkdirSync(join(bunDir, "bin"), { recursive: true }); + mkdirSync(zodDir, { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ + name: PKG, + version: "2.0.0", + dependencies: { bun: "1", zod: "1" }, + })); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048)); + writeFileSync(join(bunDir, "package.json"), JSON.stringify({ name: "bun" })); + writeFileSync(join(bunDir, "bin", "bun.exe"), Buffer.alloc(10 * 1024 * 1024 + 1)); + writeFileSync(join(zodDir, "package.json"), JSON.stringify({ name: "zod" })); + if (options.linkDependenciesInside !== false) { + symlinkSync(bunDir, join(packageDir, "node_modules", "bun"), "dir"); + symlinkSync(zodDir, join(packageDir, "node_modules", "zod"), "dir"); + } + return packageDir; +} + +describe("pnpm package tree verification", () => { + test("resolves dependencies through a custom virtual store and hoisted-style links", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-tree-")); + try { + const packageDir = makePackageFixture(root, join(root, "custom-virtual-store", "node_modules")); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("resolves a genuinely hoisted package from an ancestor node_modules", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-hoisted-")); + try { + const hoistedRoot = join(root, "global", "node_modules"); + const packageDir = makePackageFixture(root, hoistedRoot, { + packageDir: join(hoistedRoot, "@bitkyc08", "opencodex"), + linkDependenciesInside: false, + }); + // A hoisted group is owned by pnpm only when pnpm's own bookkeeping says so; a bare + // ancestor node_modules is somebody else's installation (#4203 review, Ingwannu). + writeFileSync(join(hoistedRoot, ".modules.yaml"), "nodeLinker: hoisted\n"); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("resolves dependencies through a pnpm package-root symlink", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-linked-tree-")); + try { + const target = makePackageFixture(root, join(root, "store", "node_modules")); + const exposed = join(root, "global", "v11", "node_modules", PKG); + mkdirSync(dirname(exposed), { recursive: true }); + symlinkSync(target, exposed, "dir"); + expect(verifyPnpmInstallTree(exposed, "2.0.0")).toEqual({ ok: true, failures: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("does not accept a package tree whose runtime dependency cannot resolve", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-tree-missing-")); + try { + const packageDir = makePackageFixture(root, join(root, "deps")); + rmSync(join(packageDir, "node_modules", "zod"), { force: true }); + expect(verifyPnpmInstallTree(packageDir, "2.0.0").ok).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("pnpm generated shims", () => { + test("verifies POSIX shims point at the active package", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shims-")); + try { + const packageDir = join(root, "global", "v11", "node_modules", PKG); + const globalBinDir = join(root, "bin"); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(globalBinDir, { recursive: true }); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); + const target = relative(globalBinDir, join(packageDir, "bin", "ocx.mjs")); + for (const name of ["ocx", "opencodex"]) { + writeFileSync(join(globalBinDir, name), `#!/bin/sh\nexec node ${target} "$@"\n`); + chmodSync(join(globalBinDir, name), 0o755); + } + expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux")).toEqual({ ok: true }); + writeFileSync(join(globalBinDir, "opencodex"), "#!/bin/sh\nexec node ../global/v11/node_modules/old/bin/ocx.mjs\n"); + expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux").ok).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("accepts a pnpm group alias when it resolves to the active package", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shim-alias-")); + try { + const activeGroup = join(root, "global", "v11", "active"); + const aliasGroup = join(root, "global", "v11", "stable-link"); + const packageDir = join(activeGroup, "node_modules", PKG); + const globalBinDir = join(root, "bin"); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(globalBinDir, { recursive: true }); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); + symlinkSync(activeGroup, aliasGroup, "dir"); + const target = relative(globalBinDir, join(aliasGroup, "node_modules", PKG, "bin", "ocx.mjs")); + for (const name of ["ocx", "opencodex"]) { + writeFileSync(join(globalBinDir, name), `#!/bin/sh\nexec node ${target} "$@"\n`); + chmodSync(join(globalBinDir, name), 0o755); + } + expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux")).toEqual({ ok: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("verifies Windows cmd and PowerShell shim forms", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-win-shims-")); + try { + const packageDir = join(root, "global", "v11", "node_modules", PKG); + const globalBinDir = join(root, "bin"); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(globalBinDir, { recursive: true }); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); + const target = relative(globalBinDir, join(packageDir, "bin", "ocx.mjs")).replaceAll("/", "\\"); + for (const name of ["ocx.cmd", "ocx.ps1", "opencodex.cmd", "opencodex.ps1"]) { + const body = name.endsWith(".cmd") + ? `@echo off\r\nnode "%~dp0\\${target}" %*\r\n` + : `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\n& "$basedir\\${target}" @args\n`; + writeFileSync(join(globalBinDir, name), body); + } + expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "win32")).toEqual({ ok: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +interface FakePnpmState { + activeVersion: string; + invalidVersions?: Set; + failVersions?: Set; + invalidShimVersions?: Set; + calls: { args: string[]; capture: boolean }[]; +} + +function fakePnpm(state: FakePnpmState, owner = ownerFor()): (args: readonly string[], capture?: boolean) => PnpmRunResult { + return (args, capture = false) => { + const normalized = [...args]; + state.calls.push({ args: normalized, capture }); + if (normalized[0] === "list") { + return { + status: 0, + stdout: JSON.stringify([{ + path: owner.globalRoot, + dependencies: { + [PKG]: { + version: state.activeVersion, + path: state.activeVersion === "1.0.0" + ? owner.packagePath + : `/virtual/pnpm/${state.activeVersion}`, + }, + }, + }]), + }; + } + if (normalized[0] !== "add") return { status: 1 }; + const spec = normalized.at(-1) ?? ""; + const requested = spec.slice(spec.lastIndexOf("@") + 1); + if (state.failVersions?.has(requested)) return { status: 1 }; + state.activeVersion = requested; + return { status: 0 }; + }; +} + +function fakeVerify(state: FakePnpmState, owner = ownerFor()) { + return (path: string, expectedVersion?: string) => ({ + ok: (path === owner.packagePath || path === `/virtual/pnpm/${state.activeVersion}`) + && expectedVersion === state.activeVersion + && !state.invalidVersions?.has(state.activeVersion), + }); +} + +function fakeShims(state: FakePnpmState) { + return (_path: string, _bin: string, _platform?: string) => ({ + ok: !state.invalidShimVersions?.has(state.activeVersion), + }); +} + +describe("pnpm global update", () => { + test("updates through the selected pnpm owner and verifies the new active group", () => { + const owner = ownerFor(); + const state: FakePnpmState = { activeVersion: "1.0.0", calls: [] }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, + currentVersion: "1.0.0", + targetVersion: "2.0.0", + tag: "latest", + owner, + runPnpm: fakePnpm(state, owner), + verify: fakeVerify(state, owner), + verifyShims: fakeShims(state), + }); + + expect(result).toMatchObject({ ok: true, phase: "done", version: "2.0.0", path: "/virtual/pnpm/2.0.0" }); + expect(state.calls.map(call => call.args)).toContainEqual([ + "add", + `--global-dir=${owner.globalDir}`, + `--config.global-bin-dir=${owner.globalBinDir}`, + "-g", "--allow-build=bun", `${PKG}@2.0.0`, + ]); + expect(state.calls.every(call => call.args[0] !== "list" || call.args.includes(`--global-dir=${owner.globalDir}`))).toBe(true); + }); + + test("does not block on a stale pre-existing shim, but requires fresh shims after update", () => { + const owner = ownerFor(); + const state: FakePnpmState = { + activeVersion: "1.0.0", + invalidShimVersions: new Set(["1.0.0"]), + calls: [], + }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner, + runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state), + }); + expect(result).toMatchObject({ ok: true, phase: "done", version: "2.0.0" }); + }); + + test("does not run a second transaction when a failed command leaves the verified old group active", () => { + const owner = ownerFor(); + const state: FakePnpmState = { activeVersion: "1.0.0", failVersions: new Set(["2.0.0"]), calls: [] }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner, + runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state), + }); + expect(result).toMatchObject({ ok: false, phase: "install", rolledBack: true, activePath: owner.packagePath }); + expect(state.calls.filter(call => call.args[0] === "add")).toHaveLength(1); + }); + + test("rolls back when a zero-exit install leaves an invalid tree or stale shim", () => { + const owner = ownerFor(); + const state: FakePnpmState = { + activeVersion: "1.0.0", + invalidVersions: new Set(["2.0.0"]), + invalidShimVersions: new Set(["2.0.0"]), + calls: [], + }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner, + runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state), + }); + expect(result).toMatchObject({ + ok: false, phase: "rollback", rolledBack: true, activePath: owner.packagePath, + }); + expect(state.calls.filter(call => call.args[0] === "add").map(call => call.args.at(-1))).toEqual([ + `${PKG}@2.0.0`, `${PKG}@1.0.0`, + ]); + }); + + test("does not claim rollback when the restored group cannot be verified", () => { + const owner = ownerFor(); + const state: FakePnpmState = { + activeVersion: "1.0.0", + invalidVersions: new Set(["2.0.0"]), + failVersions: new Set(["1.0.0"]), + calls: [], + }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner, + runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state), + }); + expect(result).toMatchObject({ ok: false, phase: "rollback", rolledBack: false }); + expect((result as { activePath?: string }).activePath).toBeUndefined(); + }); +}); + +describe("shared registry integrity pre-flight", () => { + test("fails closed only for successful metadata without sha512 and skips query failures", () => { + expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({ + status: 0, + stdout: '"sha512-abc="', + }))).toEqual({ ok: true, integrity: "sha512-abc=" }); + expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({ status: 0, stdout: "sha1-deprecated" })).ok).toBe(false); + expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({ status: 1 })).ok).toBe("skipped"); + }); + + test("both launcher and Bun worker use the shared helper before stopping", () => { + const launcher = readFileSync(join(dirname(import.meta.dir), "..", "bin", "ocx.mjs"), "utf8"); + const update = readFileSync(join(dirname(import.meta.dir), "..", "src", "update", "index.ts"), "utf8"); + expect(launcher).toContain("checkRegistryPackageIntegrity"); + expect(launcher.indexOf("checkRegistryPackageIntegrity")).toBeLessThan(launcher.indexOf("Stopping the running proxy")); + expect(update).toContain("checkRegistryPackageIntegrity"); + }); +}); + +describe("pnpm update command", () => { + test("uses pnpm's native global build approval and pins resolved versions", () => { + expect(updateCommand("pnpm", "latest", "2.50.0")).toEqual({ + bin: "pnpm", + args: ["add", "-g", "--allow-build=bun", `${PKG}@2.50.0`], + }); + expect(updateCommandStr("pnpm", "latest", "2.50.0")).toContain("pnpm add -g --allow-build=bun"); + expect(updateCommand("pnpm", "latest").args.at(-1)).toBe(`${PKG}@latest`); + }); +}); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index f52c53a008..7fa117758b 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -524,7 +524,7 @@ describe("update stops the running proxy before replacing files", () => { test("bun/source update path gates on the pid file and spawns 'stop' before the package manager", () => { expect(updateSource).toContain('spawnSync(process.execPath, selfLaunchArgv(["stop"])'); const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); - const updateAt = updateSource.indexOf("spawnSync(target.bin, target.args"); + const updateAt = updateSource.indexOf("spawnSync(target.bin, target.args", stopAt); expect(stopAt).toBeGreaterThan(-1); expect(updateAt).toBeGreaterThan(-1); expect(stopAt).toBeLessThan(updateAt); @@ -532,7 +532,7 @@ describe("update stops the running proxy before replacing files", () => { }); test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => { - const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest);"); + const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner);"); const abortAt = updateSource.indexOf("aborting the update before stopping the proxy"); const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); expect(gateAt).toBeGreaterThan(-1); @@ -568,14 +568,16 @@ describe("update stops the running proxy before replacing files", () => { expect(launcherSource).toContain('existsSync(join(configDir(), "runtime-port.json"))'); }); - test("Windows npm paths resolve safely before stop and never use shell:true", () => { - const updateResolveAt = updateSource.indexOf("const target = updateSpawnTarget(bin, cmdArgs);"); + test("Windows package-manager paths resolve safely before stop and never use shell:true", () => { + const updateResolveAt = updateSource.indexOf("const target = installer === \"pnpm\" && owner"); const updateStopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); - const launcherResolveAt = launcherSource.indexOf("const installInvocation = npmInvocation("); + const launcherResolveAt = launcherSource.indexOf("const installInvocation = managerInvocation(installArgs);"); const launcherStopAt = launcherSource.indexOf('[launcher, "stop"]'); expect(updateResolveAt).toBeGreaterThan(-1); expect(launcherResolveAt).toBeGreaterThan(-1); + expect(launcherSource).toContain("resolvePnpmGlobalOwner"); + expect(launcherSource).toContain("pnpmOwnerInvocation(owner, args)"); expect(updateResolveAt).toBeLessThan(updateStopAt); expect(launcherResolveAt).toBeLessThan(launcherStopAt); expect(updateSource).not.toContain("shell: true"); @@ -653,7 +655,9 @@ describe("update stops the running proxy before replacing files", () => { writeFileSync(join(opencodexHome, "runtime-port.json"), JSON.stringify({ port, pid: 999_999_999 })); writeFileSync(fakeNpm, `#!/bin/sh case "$1" in - view) printf '2.0.0\\n' ;; + view) + if [ "$3" = "dist.integrity" ]; then printf 'sha512-testfixturevalue000000000\\n'; else printf '2.0.0\\n'; fi + ;; config) printf '%s\\n' "$OCX_FAKE_NPM_CACHE" ;; install) exit 1 ;; *) exit 1 ;; diff --git a/tests/update/update-tree-ownership.test.ts b/tests/update/update-tree-ownership.test.ts new file mode 100644 index 0000000000..c9c2e72904 --- /dev/null +++ b/tests/update/update-tree-ownership.test.ts @@ -0,0 +1,179 @@ +/** + * #4202 review (Ingwannu, blocking on PR #4203): the pnpm path may resolve a dependency + * outside the package directory, but the npm verifier must stay confined to the candidate's + * own tree. Node's resolver walks the ancestor chain, so a global npm candidate can otherwise + * satisfy its bundled-Bun requirement from a sibling package's install. Three decisions read + * that verdict — accepting the stage, rolling back after the swap, and reaping the only + * backup at boot — so a non-self-contained candidate called healthy costs the known-good copy. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + bootRestoreProbe, + verifyInstallTree, + verifyPnpmInstallTree, +} from "../../src/update/transactional-install.mjs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const PKG = "@bitkyc08/opencodex"; +const BUN_BYTES = 10 * 1024 * 1024 + 1024; + +/** A dependency directory that would satisfy the manifest if it were ever consulted. */ +function writeDependency(dir: string, name: string, opts: { truncated?: boolean } = {}): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name })); + if (name === "bun") { + writeFileSync(join(dir, "bun.exe"), Buffer.alloc(opts.truncated ? 1024 : BUN_BYTES)); + } +} + +/** The package itself, with no dependencies of its own unless the caller adds them. */ +function writePackage(packageDir: string, version: string): void { + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(join(packageDir, "node_modules"), { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ + name: PKG, version, dependencies: { bun: "1", zod: "1" }, + })); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048)); +} + +describe("#4202 install-tree dependency ownership", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-tree-ownership-")); + }); + + afterEach(() => { + removeTreeWithRetry(root); + }); + + /** Global npm layout: /lib/node_modules/{@scope/pkg,bun,zod}. */ + function globalNpmFixture(opts: { ownBun?: "intact" | "truncated" } = {}): string { + const globalRoot = join(root, "lib", "node_modules"); + const packageDir = join(globalRoot, ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + // An unrelated global installation that happens to bundle the same dependencies. + writeDependency(join(globalRoot, "bun"), "bun"); + writeDependency(join(globalRoot, "zod"), "zod"); + if (opts.ownBun) { + writeDependency(join(packageDir, "node_modules", "bun"), "bun", { + truncated: opts.ownBun === "truncated", + }); + } + return packageDir; + } + + test("an npm candidate missing its own dependencies is not saved by an ancestor install", () => { + const packageDir = globalNpmFixture(); + const result = verifyInstallTree(packageDir, "2.0.0"); + expect(result.ok).toBe(false); + expect(result.failures).toContain("sentinel dependency missing: bun"); + expect(result.failures).toContain("sentinel dependency missing: zod"); + }); + + test("an npm candidate with a truncated own Bun is not rescued by an intact ancestor Bun", () => { + const packageDir = globalNpmFixture({ ownBun: "truncated" }); + const result = verifyInstallTree(packageDir, "2.0.0"); + expect(result.ok).toBe(false); + expect(result.failures).toContain("bundled Bun binary missing or truncated (< 10MB)"); + // zod still has no copy inside the candidate, and the ancestor's does not count. + expect(result.failures).toContain("sentinel dependency missing: zod"); + }); + + test("a self-contained npm candidate still verifies", () => { + const packageDir = globalNpmFixture({ ownBun: "intact" }); + writeDependency(join(packageDir, "node_modules", "zod"), "zod"); + expect(verifyInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("boot restore keeps the backup when the live tree only resolves through an ancestor", () => { + // Live tree in a global npm layout, its dependencies supplied only by the sibling install. + const globalRoot = join(root, "lib", "node_modules"); + const scopeDir = join(globalRoot, "@bitkyc08"); + const packageDir = join(scopeDir, "opencodex"); + writePackage(packageDir, "2.0.0"); + writeDependency(join(globalRoot, "bun"), "bun"); + writeDependency(join(globalRoot, "zod"), "zod"); + // A known-good backup from the previous swap, sitting where bootRestoreProbe looks. + const backup = join(scopeDir, ".ocx-backup-2026-01-01T00-00-00-000Z", "opencodex"); + writePackage(backup, "1.0.0"); + writeDependency(join(backup, "node_modules", "bun"), "bun"); + writeDependency(join(backup, "node_modules", "zod"), "zod"); + + const probe = bootRestoreProbe(packageDir); + + expect(probe.action).toBe("restored"); + expect(existsSync(join(packageDir, "node_modules", "bun", "package.json"))).toBe(true); + }); + + test("boot restore still reaps the backup for a genuinely self-contained live tree", () => { + const scopeDir = join(root, "lib", "node_modules", "@bitkyc08"); + const packageDir = join(scopeDir, "opencodex"); + writePackage(packageDir, "2.0.0"); + writeDependency(join(packageDir, "node_modules", "bun"), "bun"); + writeDependency(join(packageDir, "node_modules", "zod"), "zod"); + const backupRoot = join(scopeDir, ".ocx-backup-2026-01-01T00-00-00-000Z"); + writePackage(join(backupRoot, "opencodex"), "1.0.0"); + + const probe = bootRestoreProbe(packageDir); + + expect(probe.action).toBe("reaped"); + expect(existsSync(backupRoot)).toBe(false); + }); + + test("the pnpm verifier refuses an ancestor root that carries no pnpm bookkeeping", () => { + // Same shape as the npm escape: a bare ancestor node_modules is somebody else's install. + const packageDir = globalNpmFixture(); + const result = verifyPnpmInstallTree(packageDir, "2.0.0"); + expect(result.ok).toBe(false); + expect(result.failures).toContain("sentinel dependency missing: bun"); + }); + + test("the pnpm verifier accepts a hoisted group that pnpm's own metadata claims", () => { + const groupRoot = join(root, "global", "v11", "node_modules"); + const packageDir = join(groupRoot, ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + writeDependency(join(groupRoot, "bun"), "bun"); + writeDependency(join(groupRoot, "zod"), "zod"); + writeFileSync(join(groupRoot, ".modules.yaml"), "nodeLinker: hoisted\n"); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("the pnpm verifier accepts a virtual-store link reached through the package's own tree", () => { + const store = join(root, "store", "v11", "node_modules", ".pnpm", "registry", "node_modules"); + const packageDir = join(root, "global", "v11", "node_modules", ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + writeDependency(join(store, "bun"), "bun"); + writeDependency(join(store, "zod"), "zod"); + // pnpm's isolated linker links each declared dependency into the package's node_modules. + symlinkSync(join(store, "bun"), join(packageDir, "node_modules", "bun"), "dir"); + symlinkSync(join(store, "zod"), join(packageDir, "node_modules", "zod"), "dir"); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("the npm verifier accepts the same virtual-store link, because the candidate owns it", () => { + // The link lives inside the candidate's own node_modules, which is the npm contract too. + const store = join(root, "store", "node_modules"); + const packageDir = join(root, "global", "node_modules", ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + writeDependency(join(store, "bun"), "bun"); + writeDependency(join(store, "zod"), "zod"); + symlinkSync(join(store, "bun"), join(packageDir, "node_modules", "bun"), "dir"); + symlinkSync(join(store, "zod"), join(packageDir, "node_modules", "zod"), "dir"); + expect(verifyInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("a package root that is itself a pnpm symlink resolves through its realpath", () => { + const target = join(root, "store", "v11", "node_modules", ".pnpm", "pkg", "node_modules", ...PKG.split("/")); + writePackage(target, "2.0.0"); + writeDependency(join(target, "node_modules", "bun"), "bun"); + writeDependency(join(target, "node_modules", "zod"), "zod"); + const exposed = join(root, "global", "v11", "node_modules", ...PKG.split("/")); + mkdirSync(join(root, "global", "v11", "node_modules", "@bitkyc08"), { recursive: true }); + symlinkSync(target, exposed, "dir"); + expect(verifyPnpmInstallTree(exposed, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); +}); From 7556be8ec1c10b556df37bdba8db31bc28131073 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:36:49 +0900 Subject: [PATCH 038/126] docs(devlog): record the #4201 quota admission and the Flash decision --- .../010_4201_quota_admission.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md diff --git a/devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md b/devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md new file mode 100644 index 0000000000..ace9c3c436 --- /dev/null +++ b/devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md @@ -0,0 +1,98 @@ +# 010 — #4201 work-phase: Responses preset quota admission + +Lane L2, branch `codex/260911-l2-catalog-provider`, rebased onto `origin/dev` `ed839a3ee`. + +## What the issue asked for + +#4201 reports two user-visible gaps behind one subscription. Switching the domestic GLM Coding +Plan from the Chat preset to the Responses preset loses the quota display and the +`glm-5.3-flash` entry, so the same plan looks halved after a wire-format change. + +## What landed + +Only the quota half. + +`keyQuotaReaderForProvider` at `src/providers/quota.ts:2909` gated the Z.AI/BigModel reader on a +provider-name list of `zai`, `glm`, `glm-cn`, `zhipu-bigmodel-coding`. The destination check +`isCanonicalZaiBaseUrl` at `:355` already accepted `https://open.bigmodel.cn/api/v1`, and +`fetchZaiQuota` at `:858` already selects the domestic monitor host and its bare-key +`Authorization` convention for that base. The name list was the whole gap: +`providerApiKeyQuotaMode` returned `unsupported` and `fetchProviderApiKeyQuotas` returned `[]` +before any request existed. + +The fix adds the one name. Eligibility stays a conjunction of the name list and the canonical-URL +guard, because that guard is what keeps the bare key from travelling to a lookalike host: a +same-named custom provider resolves no reader and therefore dispatches nothing at all. + +Regression: `tests/providers/zhipu-bigmodel-responses-quota.test.ts`, registered in +`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. Four cases — +eligibility anchored to the registry entry's own `baseUrl`, the negative controls (custom host, +pay-as-you-go endpoint, disabled, non-key auth modes), the domestic dispatch with a bare +`Authorization` and `redirect: "error"`, and a no-dispatch proof for a same-named custom +destination. + +It is a separate file on purpose: `tests/providers/provider-quota.test.ts` is contended by four +open PRs and is not in this lane's owned paths. + +## Decision: Flash is not seeded + +`glm-5.3-flash` stays out of the Responses roster, so the pull request says `Refs #4201`, not +`Closes`. + +The issue itself makes Flash conditional — "should offer `glm-5.3-flash` **if** the domestic +Responses endpoint supports it, with verified context, modalities, and reasoning metadata" — and +this lane has no endpoint-specific evidence. The registry comment at `registry.ts:2640` records +that the static roster is deliberate and that the official Codex example is a local catalog file, +not an HTTP `/models` contract, and the roster oracle at +`tests/providers/provider-registry-parity.test.ts:464` and `:508` locks that shape. The +maintainer review on the issue reaches the same conclusion: admit the quota name now, seed Flash +only after endpoint proof. + +Seeding it anyway would mean inventing a context window, modalities and a reasoning ladder for a +model this endpoint has not been observed to serve. That is the fabrication the oracle exists to +prevent, so the honest outcome is a closed quota half and an open, evidence-blocked Flash half. + +## Documentation for L7 + +`docs-site/src/content/docs/guides/providers.md` is L7's path. Two edits follow from this change; +the wording is here so L7 can land it. + +At `:947`, the eligible-preset sentence is now stale. Current text: + +> **Z.AI GLM Coding Plan quota.** The `zai`, `glm`, `glm-cn`, and `zhipu-bigmodel-coding` +> presets read `GET /api/monitor/usage/quota/limit` and do not follow redirects. + +Replacement: + +> **Z.AI GLM Coding Plan quota.** The `zai`, `glm`, `glm-cn`, `zhipu-bigmodel-coding`, and +> `zhipu-bigmodel-responses` presets read `GET /api/monitor/usage/quota/limit` and do not follow +> redirects. + +In the "BigModel Coding Plan over Responses" section, after the paragraph ending at `:783`, one +sentence closing the parity question the issue raises: + +> Quota comes from the same Coding Plan probe as the Chat preset, because it is the same +> subscription: the Responses endpoint `/api/v1` is a canonical destination for that probe and +> uses the same domestic bare-key `Authorization`. + +The Flash restriction is already documented at `:788` ("`glm-5.3-flash` is not seeded here +because its exact Responses metadata is not verified"), which is the wording #4201 asks for. No +change needed there beyond keeping it true. + +## Overlap reported to the orchestrator + +Open draft #4210 by `Ingwannu` (head `b9109a151`, `REVIEW_REQUIRED`) fixes the same selector line. +The packet's recorded decision is to implement #4201 independently and report the overlap rather +than merge the two lines of work, which is what happened: the diffs collide on +`src/providers/quota.ts:2909` because there is no second way to express this fix. #4210 also edits +`tests/providers/provider-quota.test.ts` and `docs-site/.../providers.md`, neither of which this +lane owns. `Ingwannu` is credited with a `Co-authored-by` trailer per `AGENTS.md`, since this +supersedes their quota hunk. + +## Verification + +Local product suite, typecheck and GUI build: **NOT RUN**, by operator instruction. Hosted CI on +the exact pushed head is the evidence. Two read-only `xai/grok-4.6` subagents reviewed the change: +one for blast radius, second callers and transport correctness, one adversarial pass over the +commit for type, runtime and layout-registration defects. + From 2d79b3969978bed4ebe2920cfdba9d1f10555fe7 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:39:09 +0900 Subject: [PATCH 039/126] docs(devlog): record the upstream evidence behind the Flash deferral The quota half of #4201 landed with Flash left out for lack of endpoint-specific evidence. This records what BigModel actually publishes as of 2026-09-11, so the next unit does not repeat the search: the plan-level pages say every tier supports GLM-5.3 and GLM-5.3-Flash and that a call to GLM-5-Turbo is auto-switched to Flash, while the page specific to the Codex/Responses endpoint still declares a two-model catalog without it. Availability is published per subscription and destinations per protocol, and no page joins the two, which is why the plan-level statement is not the endpoint proof the issue asks for. No product change. --- .../020_flash_upstream_evidence.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md diff --git a/devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md b/devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md new file mode 100644 index 0000000000..3df412bb90 --- /dev/null +++ b/devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md @@ -0,0 +1,49 @@ +# 020 — what upstream actually publishes about Flash on the Responses endpoint + +`010` records the decision to leave `glm-5.3-flash` out of the Responses roster. This is the +evidence behind it, gathered from BigModel's own documentation on 2026-09-11, so the next unit does +not have to rediscover it. It changes nothing in the product. + +## The bar + +The issue makes Flash conditional on the domestic Responses endpoint supporting it, with verified +context, modalities and reasoning metadata, and asks for the upstream restriction to be documented +otherwise. The maintainer review says the same thing in the other direction: seed Flash only after +proof it works on that endpoint, and update the roster oracle in the same change. + +## What the vendor publishes + +| Page | What it says | +|---|---| +| `coding-plan/tool/codex.md` | Codex integrates at `https://open.bigmodel.cn/api/v1` with `wire_api = "responses"`. Its `models.json` example declares `glm-5.3` and `glm-5-turbo`. Flash appears nowhere on the page. Unchanged since it was checked on 2026-09-07. | +| `coding-plan/overview.md` | "所有套餐均支持 GLM-5.3、GLM-5.3-Flash." And: a call to `GLM-5-Turbo` or `GLM-4.7` is auto-switched to `GLM-5.3-Flash`. | +| `coding-plan/latest-model.md` | The plan supports GLM-5.3 and GLM-5.3-Flash for every tier, and lists the three protocol endpoints with Codex on `/api/v1`. The switching procedures it gives are for Claude Code (Anthropic wire) and Cline (Chat wire). There is no Codex procedure. | +| `coding-plan/faq.md` | The same plan-level roster: `GLM-5.3`, `GLM-5.3-Flash`. | + +## Why that is not the proof the issue asks for + +Availability is published per subscription; destinations are published per protocol. Every page that +names Flash is making a plan statement, every page that names `/api/v1` is making a protocol +statement, and no published page joins the two. The one page specific to this endpoint declares a +two-model catalog without Flash — the same oracle the preset was built from, and the same one the +roster test locks. + +The auto-switch line is the strongest single fact and it cuts both ways. Flash weights are already +reachable through this preset, because `glm-5-turbo` is in the shipped roster and the plan routes +that id to Flash. That is not evidence the endpoint accepts the literal id `glm-5.3-flash`, which is +what adding the roster entry would assert. + +Metadata is the harder half anyway. Seeding a model means declaring a context window, modalities, a +reasoning ladder and a default effort. Every Flash number in this repository comes from the Z.AI or +BigModel Chat rows or a Command Code page scrape, none measured on `/api/v1`, and the two rows +already disagree where they overlap: `glm-5.3` is `1_048_576` on Responses and `1_000_000` on Chat. +Copying the Chat numbers across would publish a catalog entry nobody has verified. + +## What would settle it + +One authenticated `POST https://open.bigmodel.cn/api/v1/responses` with `model: "glm-5.3-flash"` on a +Coding Plan key, plus whatever the vendor publishes for that model's Responses context window and +modalities. A live credentialed probe was outside this round; #4210's author declined it for the same +reason. Until then the accurate product statement is the one already in the docs: this preset ships +the roster the vendor documents for it. + From 030316c5dbffe81f31dc12340829b42de22e2bbc Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:49:40 +0900 Subject: [PATCH 040/126] fix(responses): classify Codex WS failures instead of restating them A long Codex thread that dies on the WebSocket transport reports one of two sentences, and neither distinguishes the cases it is true of: a socket that was never answered, a socket that carried only quota control frames, and a socket that died after the response was already flowing all produce the same text. The reporter of #4191 had to establish that much by toggling the proxy by hand. Add a content-free stage record to the exchange: create-frame byte count, whether the send completed, upstream frame count, how many of those the metadata channel claimed, Responses events relayed downstream, and the durations from send to first frame and to the failure. classifyCodexWsFailure reduces it to before-send, no-upstream-frame, no-response-event, or after-response-started, and the detail is appended after the existing message so the close-code tail stays one contiguous substring for every reader that matches on it. The frame is measured only when a failure message is being built, so the happy path never pays for sizing a multi-megabyte string. The classification is not a fallback-eligibility signal, and the no-replay-after- send contract is unchanged: a completed send may be executing upstream whatever the counters say. Refs #4191 --- .../010_4191_ws_failure_classification.md | 102 ++++++++ scripts/test-layout/layout.json | 1 + src/server/responses/codex-ws-exchange.ts | 40 ++- src/server/responses/codex-ws-wire.ts | 79 +++++- tests/fixtures/test-layout-expected.json | 1 + tests/responses/ws-failure-stage.test.ts | 230 ++++++++++++++++++ 6 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 devlog/_plan/260911_l6_streaming_tools/010_4191_ws_failure_classification.md create mode 100644 tests/responses/ws-failure-stage.test.ts diff --git a/devlog/_plan/260911_l6_streaming_tools/010_4191_ws_failure_classification.md b/devlog/_plan/260911_l6_streaming_tools/010_4191_ws_failure_classification.md new file mode 100644 index 0000000000..f141f3ca56 --- /dev/null +++ b/devlog/_plan/260911_l6_streaming_tools/010_4191_ws_failure_classification.md @@ -0,0 +1,102 @@ +# 010 — #4191: classify the WebSocket failure instead of restating it + +Unit: `devlog/_plan/260911_l6_streaming_tools`. Lane L6, work-phase 1. +Issue: [#4191](https://github.com/lidge-jun/opencodex/issues/4191). + +## What the report actually contains + +A long Codex thread failed for hours through the proxy and recovered instantly when the +proxy was bypassed — same account, model, machine, repository and thread. Two messages +appeared, alternating: + + codex websocket closed before a Responses terminal event (close 1006 Connection ended) + codex websocket response prelude timed out + +Neither sentence distinguishes the cases it is true of. The reporter compensated by running +an A/B toggle by hand, which is why the only usable evidence in a very careful report is +"it works when OpenCodex is off". That is the defect this work-phase addresses. + +## Where the prelude budget goes + +Read `codex-ws-exchange.ts` at the frozen base: `preludeTimer` is armed once in `onOpen` +immediately after `ws.send()` succeeds, and the only thing that clears it is +`commitResponse()`. `commitResponse()` runs from `onMessage` only on the **non-control** +branch — a frame the metadata channel did not claim. `codex.rate_limits` and +`codex.response.metadata` are claimed by `CodexWsMetadata.consume()` and set +`controlFrame = true`, so they never reach it. + +So the 90 s budget is measured from send to the first *Responses* event, and upstream +liveness does not extend it. A socket that answers in 40 ms with a quota frame, then keeps +sending quota updates while the backend works through a very large replayed thread, still +dies at exactly 90 s — and reports the same sentence as a socket that was never answered at +all. That is the mechanism the report's second error most likely describes, and before this +change nothing in the message could tell the two apart. + +The size story is next to it. `codexWsCreateFrameExceedsLimit` preflights the create frame +at 16 MiB − 64 KiB and routes an oversized turn to HTTP SSE, so the very largest threads are +already safe. The band immediately below the limit is not: it still dials the socket, and a +long full-replay thread sits in that band. Whether this reporter's thread was there is not +knowable from the report, which is precisely why the create-frame byte count belongs in the +message. + +## What was implemented + +A content-free stage record, `CodexWsFailureStage` in `codex-ws-wire.ts`, carrying the +create-frame byte count, whether the send completed, the number of upstream frames, how many +of those the metadata channel claimed, how many Responses events were relayed downstream, +and two durations — send to first frame, and send to failure. `classifyCodexWsFailure` +reduces that to one of four causes: `before-send`, `no-upstream-frame`, +`no-response-event`, `after-response-started`. `codexWsFailureDetail` renders it as a +suffix appended after the existing message, including after the close-code tail, so +`(close 1006 Connection ended)` remains one contiguous substring for every existing reader. + +`codex-ws-exchange.ts` keeps the counters and stamps `sentAt` after a successful send. The +frame is measured lazily, only when a failure message is being built, so the happy path never +pays for sizing a multi-megabyte string. The three paths that end an already-open exchange +without a terminal event now carry the detail: the prelude timeout, the close-before-terminal +message, and the transport error. The size- and queue-limit failures already name their own +precise cause and were left alone. + +The reporter asked for five diagnostics. Four are now in the message the client receives: +serialized frame size, whether the send completed, elapsed time to the first upstream event +and to the failure, and whether any downstream Responses bytes were emitted. The fifth — +preserving the upstream close code — was already there and is unchanged. + +## Deliberately not implemented + +The issue and its two maintainer comments leave three things open. All three come back as a +report, per the lane packet. + +**Automatic HTTP/SSE fallback after an open socket dies.** `failStream` treats a completed +send as possibly executing upstream, and settles as a body failure rather than a resendable +rejection. The maintainer comment on the issue is explicit that `responseCommitted === false` +and zero downstream bytes are *not* proof the upstream did not accept or execute the frame, +so a resend gated on either can duplicate a turn. The classification added here must not be +read as a fallback-eligibility signal; the type comment says so at the definition, because +`no-upstream-frame` is exactly the value a future reader would be tempted to misuse. + +**A configurable or longer prelude budget.** #3976 asked for this and 90 s is already three +times the original 30 s. Raising a fixed constant without a reproduction is guesswork, and +the finding above suggests the real question is different: whether upstream liveness on the +control channel should extend the budget at all, or whether a thread that produces only quota +frames for 90 s should be refused earlier and more clearly. Both are policy, and no issue has +fixed that policy. + +**A size preflight below the current ceiling.** The band under 16 MiB − 64 KiB is a real gap, +but narrowing WS eligibility by predicted frame size or expected time-to-first-token changes +which turns take which transport for every user, not only failing ones. It needs its own +issue with measurements, and the byte count now in the failure message is what would supply +them. + +## Verification + +Focused regression test at `tests/responses/ws-failure-stage.test.ts`, registered in +`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. It covers +the classifier's four stages, the renderer's exact output, the contiguity of the close-code +tail that existing assertions depend on, and four end-to-end cases through the fake socket: +an unanswered close, a quota-only close, a close after relayed events, and the prelude +timeout under fake timers. + +Local suite, typecheck and build: NOT RUN, by operator instruction for this dispatch round. +Hosted CI on the pushed head is the evidence. + diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..cee1514e55 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1304,6 +1304,7 @@ "winsw-stop-hardening.test.ts": "windows", "winsw.test.ts": "service", "ws-endpoint.test.ts": "responses", + "ws-failure-stage.test.ts": "responses", "ws-upstream-reuse.test.ts": "responses", "ws-upstream.test.ts": "responses", "xai-client.test.ts": "images", diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 31813756ea..64a8e5aba4 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -5,7 +5,8 @@ import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-w import { CodexWsCorrelation } from "./codex-ws-correlation"; import type { CodexWsSession } from "./codex-ws-session"; import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, - MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage } from "./codex-ws-wire"; + MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage, + codexWsFailureDetail, type CodexWsFailureStage } from "./codex-ws-wire"; interface ExchangeOptions { session: CodexWsSession; @@ -94,6 +95,14 @@ export function codexWsExchange(options: ExchangeOptions): Promise { let received = false; let responseCommitted = false; let terminal = false; + // #4191: the counters behind the failure classification. A user whose long + // thread died here could not tell an unanswered socket from one that carried + // only quota frames, because both arrived as the same one-line message. + let upstreamFrames = 0; + let controlFrames = 0; + let relayedEvents = 0; + let sentAt: number | null = null; + let firstFrameAt: number | null = null; let controller: ReadableStreamDefaultController | null = null; const encoder = new TextEncoder(); const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null; @@ -123,6 +132,21 @@ export function codexWsExchange(options: ExchangeOptions): Promise { ws.removeEventListener("error", onError); }; + /** + * Snapshot the stage for a failure message. Measuring the frame is deferred + * to here so the happy path never pays for it: a full-replay thread's frame + * runs to megabytes, and this is the only place its size is worth knowing. + */ + const failureStage = (): CodexWsFailureStage => ({ + requestBytes: Buffer.byteLength(frameText, "utf8"), + sent, + upstreamFrames, + controlFrames, + relayedEvents, + firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null, + elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null, + }); + const commitResponse = () => { if (responseCommitted) return; responseCommitted = true; @@ -192,6 +216,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { sent = true; try { ws.send(frameText); + sentAt = Date.now(); } catch { if (received || responseCommitted) { if (terminal) session.dispose(); @@ -211,13 +236,18 @@ export function codexWsExchange(options: ExchangeOptions): Promise { } if (!metadata) commitResponse(); else if (!responseCommitted && !terminal) { - preludeTimer = setTimeout(() => failStream("codex websocket response prelude timed out"), CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); + preludeTimer = setTimeout( + () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`), + CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, + ); } }; const onMessage = (event: MessageEvent) => { if (!controller || terminal) return; received = true; + upstreamFrames += 1; + if (firstFrameAt === null) firstFrameAt = Date.now(); const text = typeof event.data === "string" ? event.data : ""; if (!text) return; // UTF-8 byte length is always at least the JS string length. Reject this @@ -243,6 +273,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { if (sanitized !== null) { relayText = sanitized; controlFrame = true; + controlFrames += 1; } } catch (error) { failStream(error); @@ -296,6 +327,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { failStream("codex websocket response stream closed while enqueueing"); return; } + if (!controlFrame) relayedEvents += 1; if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") { const completedId = correlation?.completed(normalized.payload) ?? null; terminal = true; @@ -316,7 +348,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { resolve(sseFallback(url, init)); return; } - if (sent && !terminal) failStream(closedBeforeTerminalMessage(event)); + if (sent && !terminal) failStream(closedBeforeTerminalMessage(event, failureStage())); }; const onError = () => { @@ -327,7 +359,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { cleanup(); session.dispose(); resolve(sseFallback(url, init)); - } else failStream("codex websocket transport error"); + } else failStream(`codex websocket transport error${codexWsFailureDetail(failureStage())}`); }; detachOwner = session.bindOwner(reason => cancelExchange(reason)); ws.addEventListener("open", onOpen); diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 271e7280bb..770ce21b54 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -50,6 +50,72 @@ export function markCodexWsResponse(response: Response, observed: boolean): void const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event"; +/** + * Content-free stage record for an exchange that ended without a Responses + * terminal event (#4191). + * + * The field report that drove this could not be told apart from a network + * outage, because every such failure reached the user as one of two bare + * sentences. Both are true of a socket that was never answered, a socket that + * carried only quota control frames, and a socket that died mid-response — + * three different upstream stories with three different owners. These counters + * are the smallest set that separates them, and every one of them is a size, a + * count, or a duration: no request body, no header, no account identifier, and + * no conversation text can reach a message built from this record. + */ +export type CodexWsFailureStage = { + /** UTF-8 size of the `response.create` frame this exchange dialled with. */ + requestBytes: number; + /** True once `ws.send()` returned, so the turn may be executing upstream. */ + sent: boolean; + /** Frames the socket delivered, of any kind, including ones that did not parse. */ + upstreamFrames: number; + /** Frames the metadata channel claimed (quota, response metadata). */ + controlFrames: number; + /** Responses events actually written to the downstream SSE body. */ + relayedEvents: number; + /** Milliseconds from send to the first upstream frame; null when none arrived. */ + firstFrameMs: number | null; + /** Milliseconds from send to this failure; null when the failure predates the send. */ + elapsedMs: number | null; +}; + +/** + * Which upstream story the counters tell. Ordered by how much the upstream had + * committed to, because that is what decides who owns the failure — and, for a + * future maintainer reading #4191, it is deliberately NOT a fallback-eligibility + * signal. `no-upstream-frame` does not mean the frame was not accepted; the + * no-replay-after-send contract in `codex-ws-exchange.ts` stands regardless of + * what this classifier says. + */ +export type CodexWsFailureCause = + | "before-send" + | "no-upstream-frame" + | "no-response-event" + | "after-response-started"; + +export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailureCause { + if (!stage.sent) return "before-send"; + if (stage.relayedEvents > 0) return "after-response-started"; + if (stage.upstreamFrames === 0) return "no-upstream-frame"; + return "no-response-event"; +} + +/** + * Render the stage as a suffix appended to an existing failure message. + * + * It is a suffix, not an interpolation, on purpose: the close-code tail these + * messages already carry is matched as a contiguous substring by the callers + * and tests that read it, so nothing may be inserted ahead of it. + */ +export function codexWsFailureDetail(stage: CodexWsFailureStage): string { + const duration = (value: number | null): string => (value === null ? "n/a" : `${value}ms`); + return ` [cause=${classifyCodexWsFailure(stage)} request=${stage.requestBytes}B` + + ` sent=${stage.sent ? "yes" : "no"} frames=${stage.upstreamFrames}` + + ` control=${stage.controlFrames} relayed=${stage.relayedEvents}` + + ` first-frame=${duration(stage.firstFrameMs)} elapsed=${duration(stage.elapsedMs)}]`; +} + export type ResponsesWsRelayEvent = { type: string; text: string; @@ -111,18 +177,23 @@ export function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEv * inspector, so `/api/logs` keeps neither this message nor a specific code — * only `streamAborted`. Machine-readable typing would mean changing the error * taxonomy, which is deliberately out of scope for this transport fix. + * + * When a stage is supplied its detail is appended last, after the close-code + * tail, so the code and reason stay one contiguous substring. */ -export function closedBeforeTerminalMessage(event: unknown): string { +export function closedBeforeTerminalMessage(event: unknown, stage?: CodexWsFailureStage): string { const detail = event as { code?: unknown; reason?: unknown } | null | undefined; const code = typeof detail?.code === "number" ? detail.code : null; const reason = typeof detail?.reason === "string" ? detail.reason.trim() : ""; - if (code === null) return CLOSED_BEFORE_TERMINAL; + const stageDetail = stage ? codexWsFailureDetail(stage) : ""; + if (code === null) return `${CLOSED_BEFORE_TERMINAL}${stageDetail}`; const suffix = reason ? ` ${code} ${reason}` : ` ${code}`; if (code === WS_CLOSE_MESSAGE_TOO_BIG) { return `codex websocket rejected the request frame as too large (close${suffix});` - + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`; + + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport` + + stageDetail; } - return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`; + return `${CLOSED_BEFORE_TERMINAL} (close${suffix})${stageDetail}`; } /** diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..486a6de544 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1139,6 +1139,7 @@ "winsw-stop-hardening.test.ts": "windows", "winsw.test.ts": "service", "ws-endpoint.test.ts": "responses", + "ws-failure-stage.test.ts": "responses", "ws-upstream-reuse.test.ts": "responses", "ws-upstream.test.ts": "responses", "xai-client.test.ts": "images", diff --git a/tests/responses/ws-failure-stage.test.ts b/tests/responses/ws-failure-stage.test.ts new file mode 100644 index 0000000000..af4db2136a --- /dev/null +++ b/tests/responses/ws-failure-stage.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { + classifyCodexWsFailure, + closedBeforeTerminalMessage, + codexWsFailureDetail, + type CodexWsFailureStage, +} from "../../src/server/responses/codex-ws-wire"; +import { + codexWsUpstreamFetch, + CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, +} from "../../src/server/responses/ws-upstream"; + +/** + * #4191: a long Codex thread died only through the proxy, and every variant of + * that death arrived as the same one-line message. The reporter could not tell + * an unanswered socket from one that had already started replying, so the only + * usable evidence in the whole report was an A/B toggle. These cases hold the + * transport to naming the stage it failed at. + */ + +const CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"; +const BOUNDED_WS_RUNTIME = "1.4.0"; + +type Listener = (event: unknown) => void; + +/** Minimal scriptable stand-in for Bun's WebSocket, mirroring `ws-upstream.test.ts`. */ +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + static script: (ws: FakeWebSocket) => void = () => {}; + url: string; + sent: string[] = []; + listeners = new Map(); + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + queueMicrotask(() => FakeWebSocket.script(this)); + } + + addEventListener(type: string, listener: Listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.set(type, (this.listeners.get(type) ?? []).filter(value => value !== listener)); + } + + emit(type: string, event: unknown = {}) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + send(data: string) { + this.sent.push(data); + } + + close() {} +} + +const RealWebSocket = globalThis.WebSocket; + +function installFake(script: (ws: FakeWebSocket) => void) { + FakeWebSocket.script = script; + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; +} + +function streamingInit(): RequestInit { + return { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer test" }, + body: JSON.stringify({ model: "gpt-5.5", stream: true }), + }; +} + +function noFallback(): Promise { + throw new Error("fallback must not run after open"); +} + +function stage(overrides: Partial = {}): CodexWsFailureStage { + return { + requestBytes: 812, + sent: true, + upstreamFrames: 0, + controlFrames: 0, + relayedEvents: 0, + firstFrameMs: null, + elapsedMs: 90_003, + ...overrides, + }; +} + +async function failureMessage(script: (ws: FakeWebSocket) => void): Promise { + installFake(script); + const response = await codexWsUpstreamFetch( + CODEX_URL, + streamingInit(), + noFallback as unknown as typeof fetch, + BOUNDED_WS_RUNTIME, + ); + try { + await response.text(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error("expected the relayed body to fail"); +} + +beforeEach(() => { + FakeWebSocket.instances = []; + FakeWebSocket.script = () => {}; +}); + +afterEach(() => { + globalThis.WebSocket = RealWebSocket; + FakeWebSocket.instances = []; + FakeWebSocket.script = () => {}; +}); + +describe("codex WS failure classification", () => { + test("separates the four stages a dead exchange can be in", () => { + expect(classifyCodexWsFailure(stage({ sent: false, elapsedMs: null }))).toBe("before-send"); + expect(classifyCodexWsFailure(stage())).toBe("no-upstream-frame"); + // Quota control frames prove the upstream answered; they are not a response. + expect(classifyCodexWsFailure(stage({ upstreamFrames: 3, controlFrames: 3 }))).toBe("no-response-event"); + expect(classifyCodexWsFailure(stage({ upstreamFrames: 9, controlFrames: 2, relayedEvents: 7 }))) + .toBe("after-response-started"); + }); + + test("a frame that never left outranks the counters behind it", () => { + // The send is what makes a turn possibly live upstream, so it is read first. + expect(classifyCodexWsFailure(stage({ sent: false, upstreamFrames: 4, relayedEvents: 2 }))) + .toBe("before-send"); + }); + + 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" + + " first-frame=41ms elapsed=90003ms]", + ); + expect(codexWsFailureDetail(stage({ sent: false, elapsedMs: null }))).toBe( + " [cause=before-send request=812B sent=no frames=0 control=0 relayed=0" + + " first-frame=n/a elapsed=n/a]", + ); + }); +}); + +describe("closedBeforeTerminalMessage", () => { + test("keeps the close code contiguous and appends the stage last", () => { + const message = closedBeforeTerminalMessage({ code: 1006, reason: "Connection ended" }, stage()); + // The close tail is read as one substring by existing callers and tests. + expect(message).toContain("closed before a Responses terminal event (close 1006 Connection ended)"); + expect(message.endsWith(codexWsFailureDetail(stage()))).toBe(true); + }); + + test("leaves the oversized-frame guidance intact ahead of the stage", () => { + const message = closedBeforeTerminalMessage({ code: 1009, reason: "Message Too Big" }, stage()); + expect(message).toMatch(/rejected the request frame as too large \(close 1009 Message Too Big\)/); + expect(message).toContain("must use the HTTP SSE transport [cause="); + }); + + test("omits the stage entirely when none is supplied", () => { + expect(closedBeforeTerminalMessage({ code: 1006 })) + .toBe("codex websocket closed before a Responses terminal event (close 1006)"); + expect(closedBeforeTerminalMessage(null)) + .toBe("codex websocket closed before a Responses terminal event"); + }); +}); + +describe("codexWsUpstreamFetch failure reporting", () => { + test("names an unanswered socket, and measures the frame it actually sent", async () => { + const message = await failureMessage(ws => { + ws.emit("open", {}); + ws.emit("close", { code: 1006, reason: "Connection ended" }); + }); + const sentBytes = Buffer.byteLength(FakeWebSocket.instances[0]!.sent[0]!, "utf8"); + expect(message).toContain("closed before a Responses terminal event (close 1006 Connection ended)"); + expect(message).toContain(`[cause=no-upstream-frame request=${sentBytes}B sent=yes frames=0`); + expect(message).toContain("control=0 relayed=0 first-frame=n/a"); + }); + + test("distinguishes a socket that answered with quota but never started a response", async () => { + const message = await failureMessage(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ + type: "codex.rate_limits", rate_limits: { primary: { used_percent: 10, window_minutes: 10080 } }, + }) }); + ws.emit("close", { code: 1006 }); + }); + expect(message).toContain("cause=no-response-event"); + expect(message).toContain("frames=1 control=1 relayed=0"); + expect(message).toMatch(/first-frame=\d+ms/); + }); + + test("distinguishes a drop that landed after the response was already flowing", async () => { + const message = await failureMessage(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + ws.emit("message", { data: JSON.stringify({ + type: "response.output_text.delta", delta: "hi", item_id: "m1", output_index: 0, content_index: 0, + }) }); + ws.emit("close", { code: 1006 }); + }); + expect(message).toContain("cause=after-response-started"); + expect(message).toContain("frames=2 control=0 relayed=2"); + }); + + test("the prelude timeout says which stage ran out of budget", async () => { + jest.useFakeTimers(); + const opened = Promise.withResolvers(); + try { + installFake(ws => { ws.emit("open", {}); opened.resolve(); }); + const pending = codexWsUpstreamFetch( + CODEX_URL, + streamingInit(), + noFallback as unknown as typeof fetch, + BOUNDED_WS_RUNTIME, + ); + await opened.promise; + jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); + const response = await pending; + await expect(response.text()).rejects.toThrow( + /prelude timed out \[cause=no-upstream-frame request=\d+B sent=yes frames=0 control=0 relayed=0/, + ); + } finally { + jest.useRealTimers(); + } + }); +}); + From 803c5b4b27fdcdc2555dd78c70c64cf179241ccd Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:51:16 +0900 Subject: [PATCH 041/126] test(update): cover pnpm's isolated virtual store in the ownership rule An audit subagent pointed out that pnpm's default isolated linker puts a package's dependencies beside it inside .pnpm/@/node_modules, while the physical dependency lives in its own .pnpm/@ entry. The dependency is therefore neither inside the package's own tree nor a child of the group root, which is the layout src/update/install-detection.mjs already recognises first. The verifier handles it, because ownership is probed through the link farm rather than filtered from a resolved realpath, but nothing asserted it. Both directions are covered now: the instance's own link farm satisfies the tree, and a sibling entry belonging to a different instance does not. --- tests/update/update-tree-ownership.test.ts | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/update/update-tree-ownership.test.ts b/tests/update/update-tree-ownership.test.ts index c9c2e72904..f0f43c85e2 100644 --- a/tests/update/update-tree-ownership.test.ts +++ b/tests/update/update-tree-ownership.test.ts @@ -176,4 +176,36 @@ describe("#4202 install-tree dependency ownership", () => { symlinkSync(target, exposed, "dir"); expect(verifyPnpmInstallTree(exposed, "2.0.0")).toEqual({ ok: true, failures: [] }); }); + + test("the pnpm verifier accepts the default isolated store, where deps are siblings", () => { + // pnpm's isolated linker puts each dependency of X beside X inside + // .pnpm/@/node_modules, not inside X/node_modules, and the physical + // dependency lives in its own .pnpm/@ entry. The dependency is therefore + // neither in the package's own tree nor a child of the group root, which is why + // ownership has to be probed through the link farm rather than the resolved realpath. + const virtualStore = join(root, "global", "v11", "node_modules", ".pnpm"); + const instance = join(virtualStore, "@bitkyc08+opencodex@2.0.0", "node_modules"); + const packageDir = join(instance, ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + writeDependency(join(virtualStore, "bun@1.0.0", "node_modules", "bun"), "bun"); + writeDependency(join(virtualStore, "zod@1.0.0", "node_modules", "zod"), "zod"); + symlinkSync(join(virtualStore, "bun@1.0.0", "node_modules", "bun"), join(instance, "bun"), "dir"); + symlinkSync(join(virtualStore, "zod@1.0.0", "node_modules", "zod"), join(instance, "zod"), "dir"); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("a sibling entry in the same virtual store cannot vouch for an unrelated group", () => { + // The instance directory is per package@version, so a dependency parked in a DIFFERENT + // instance's link farm is not reachable from this one and must not satisfy it. + const virtualStore = join(root, "global", "v11", "node_modules", ".pnpm"); + const instance = join(virtualStore, "@bitkyc08+opencodex@2.0.0", "node_modules"); + const packageDir = join(instance, ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + const otherInstance = join(virtualStore, "something-else@1.0.0", "node_modules"); + writeDependency(join(otherInstance, "bun"), "bun"); + writeDependency(join(otherInstance, "zod"), "zod"); + const result = verifyPnpmInstallTree(packageDir, "2.0.0"); + expect(result.ok).toBe(false); + expect(result.failures).toContain("sentinel dependency missing: bun"); + }); }); From abec9ee51e62bc9f194c670abee928c3320f1feb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:54:04 +0900 Subject: [PATCH 042/126] fix(codex): name the account that left the pool instead of only dropping it isAccountNeedsReauth() made an account unselectable and told callers nothing, so every surface that wanted to explain a refusal had to re-derive the cause somewhere else. That is how an operator ends up reading a healthy account card while routing is quietly dropping the account and the model it backed. codexAccountUnusableReason() now holds every branch of the decision and returns the cause, and isCodexAccountUsable() is its boolean projection rather than a second copy of the same checks. A reason cannot name a cause routing did not use, and routing cannot refuse an account for a cause no surface can name. The Codex account surface carries that through: poolAccountDto and the main row emit reauthReason alongside needsReauth, separating a credential that was never stored from a refresh that keeps failing and from a rejected usage lookup. /api/oauth/accounts already used that field name. nativeMainRefreshFailureResponse stays a retryable 503 with Retry-After, because the refresh may still succeed, but no longer reads as a pure transient fault: it now says that a persistent failure means the main account needs reauthentication. The pool-account 401 is deliberately unchanged. server-search.test.ts asserts that message must not carry the account id, alias, or email. Refs #4212 --- .../docs/fr/guides/codex-integration.md | 6 + .../content/docs/guides/codex-integration.md | 6 + .../docs/ja/guides/codex-integration.md | 6 + .../docs/ko/guides/codex-integration.md | 6 + .../docs/ru/guides/codex-integration.md | 6 + .../docs/tr/guides/codex-integration.md | 6 + .../docs/zh-cn/guides/codex-integration.md | 6 + .../docs/zh-tw/guides/codex-integration.md | 6 + scripts/test-layout/layout.json | 1 + src/codex/account-usability.ts | 60 ++++++-- src/codex/auth-api.ts | 46 +++++- src/server/responses/codex-auth-error.ts | 7 +- .../codex-account-unusable-reason.test.ts | 142 ++++++++++++++++++ .../codex-integration/codex-auth-api.test.ts | 7 +- tests/fixtures/test-layout-expected.json | 1 + 15 files changed, 295 insertions(+), 17 deletions(-) create mode 100644 tests/codex-integration/codex-account-unusable-reason.test.ts 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 bd8157e747..1d9704e55f 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -383,6 +383,12 @@ Si la lecture authentifiée des quotas avec le nouveau jeton OAuth confirme un q La revalidation en arrière-plan est distincte et désactivée par défaut. Elle nécessite Token Guardian, la politique `proactive` du fournisseur `openai` et `tokenGuardian.codexWarmupEnabled`, et ignore les comptes dont la validation d’inscription est en attente. +### Pourquoi un compte a cessé de servir les requêtes + +Lorsqu'un compte quitte la sélection du pool, la raison accompagne la décision au lieu d'être recalculée pour l'affichage : une interface ne peut donc pas présenter un compte comme sain pendant que le routage l'écarte. `GET /api/codex-auth/accounts` expose `reauthReason` à côté de `needsReauth` pour chaque compte : `missing_credential` si aucun identifiant n'a été enregistré, `refresh_failed` si le renouvellement échoue de façon répétée, et `quota_unauthorized` si la lecture des quotas elle-même a été refusée. + +Un renouvellement du compte principal qui n'aboutit pas répond toujours `503` avec `Retry-After`, car une nouvelle tentative peut réussir. Le message précise désormais qu'un échec persistant signifie que le compte principal doit être réauthentifié, au lieu de demander seulement de réessayer. + ## Restauration de Codex natif `ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 397ef277c6..c1e296eb49 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -714,6 +714,12 @@ If the new OAuth credential's authenticated usage lookup confirms an exhausted 5 Background revalidation is separate and off by default. It requires Token Guardian, the `openai` provider's `proactive` refresh policy, and `tokenGuardian.codexWarmupEnabled`. It skips accounts awaiting deferred registration validation. +### Why an account stopped serving requests + +When an account leaves pool selection, the reason travels with the decision instead of being recomputed for display, so a surface can never report an account healthy while routing is dropping it. `GET /api/codex-auth/accounts` carries `reauthReason` next to `needsReauth` on each account: `missing_credential` for a credential that was never stored, `refresh_failed` for a credential refresh that keeps failing, and `quota_unauthorized` when the usage lookup itself was rejected. + +A main-account refresh that does not complete still answers `503` with `Retry-After`, because a retry may still succeed. The message now adds that a failure which persists means the main account needs reauthentication, rather than only asking for another attempt. + ## Restoring native Codex `ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. 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 58181c0aba..e64a87e77b 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -249,6 +249,12 @@ ocx service install # persistent: auto-starts on login and respawns on crash バックグラウンド再検証は別機能で既定では無効です。Token Guardian、`openai` の `proactive` 更新ポリシー、`tokenGuardian.codexWarmupEnabled` が必要で、登録検証待ちのアカウントは除外します。 +### アカウントがリクエストを処理しなくなった理由 + +アカウントがプール選択から外れるとき、その理由は表示用に再計算されるのではなく判断とともに伝わります。そのため、ルーティングが除外している最中に画面が正常と表示することはありません。`GET /api/codex-auth/accounts` は各アカウントの `needsReauth` と並べて `reauthReason` を返します。資格情報が保存されていない場合は `missing_credential`、更新が繰り返し失敗する場合は `refresh_failed`、使用量の取得自体が拒否された場合は `quota_unauthorized` です。 + +メインアカウントの更新が完了しない場合も、再試行で成功する可能性があるため `Retry-After` 付きの `503` を返します。ただしメッセージには、失敗が続くならメインアカウントの再認証が必要である旨を加えました。 + ## ネイティブ Codexの復元 `ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ 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 391a3e63fc..12c26d382e 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -260,6 +260,12 @@ ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 별도의 백그라운드 재검증은 기본적으로 꺼져 있습니다. Token Guardian, `openai`의 `proactive` 갱신 정책, `tokenGuardian.codexWarmupEnabled`가 필요하며 등록 검증 대기 계정은 제외합니다. +### 계정이 요청을 처리하지 못하게 된 이유 + +계정이 풀 선택에서 빠질 때 그 이유는 표시용으로 다시 계산되지 않고 판단과 함께 전달됩니다. 라우팅이 계정을 제외하는 동안 화면에서만 정상으로 보이는 일이 생기지 않습니다. `GET /api/codex-auth/accounts`는 계정마다 `needsReauth` 옆에 `reauthReason`을 함께 반환합니다. 자격 증명이 저장된 적 없으면 `missing_credential`, 갱신이 계속 실패하면 `refresh_failed`, 사용량 조회 자체가 거부되면 `quota_unauthorized`입니다. + +메인 계정 갱신이 끝나지 않은 경우에도 재시도로 성공할 수 있으므로 `Retry-After`와 함께 `503`을 반환합니다. 다만 실패가 계속되면 메인 계정을 다시 인증해야 한다는 내용을 메시지에 덧붙였습니다. + ## 네이티브 Codex 복원 `ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 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 a22c347f2d..44dcc35bff 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -376,6 +376,12 @@ v1/base/v2 при делегировании и fallback — в Фоновая проверка — отдельная функция, выключенная по умолчанию. Она требует Token Guardian, политики `proactive` провайдера `openai` и `tokenGuardian.codexWarmupEnabled` и пропускает аккаунты, ожидающие проверки регистрации. +### Почему аккаунт перестал обслуживать запросы + +Когда аккаунт выпадает из выбора пула, причина передаётся вместе с решением, а не вычисляется заново для отображения, поэтому интерфейс не может показывать аккаунт исправным, пока маршрутизация его исключает. `GET /api/codex-auth/accounts` возвращает `reauthReason` рядом с `needsReauth` для каждого аккаунта: `missing_credential` — учётные данные не сохранялись, `refresh_failed` — обновление продолжает падать, `quota_unauthorized` — сам запрос квоты отклонён. + +Незавершённое обновление основного аккаунта по-прежнему отвечает `503` с `Retry-After`, потому что повтор может пройти. Теперь сообщение добавляет, что стойкий сбой означает необходимость повторной аутентификации основного аккаунта, а не только очередную попытку. + ## Восстановление нативного Codex `ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. 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 fcf4f8de66..fcfa9f299d 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -433,6 +433,12 @@ Yeni OAuth belirteciyle yapılan kota sorgusu 5 saatlik, haftalık veya aylık k Arka plan doğrulaması ayrı ve varsayılan olarak kapalıdır. Token Guardian, `openai` için `proactive` yenileme ilkesi ve `tokenGuardian.codexWarmupEnabled` gerektirir; kayıt doğrulaması bekleyen hesapları atlar. +### Bir hesabın istek karşılamayı bırakma nedeni + +Bir hesap havuz seçiminden çıktığında neden, görüntüleme için yeniden hesaplanmak yerine kararla birlikte taşınır; böylece yönlendirme hesabı dışarıda bırakırken hiçbir yüzey onu sağlıklı gösteremez. `GET /api/codex-auth/accounts` her hesapta `needsReauth` yanında `reauthReason` döndürür: kimlik bilgisi hiç kaydedilmediyse `missing_credential`, yenileme sürekli başarısızsa `refresh_failed`, kullanım sorgusunun kendisi reddedildiyse `quota_unauthorized`. + +Tamamlanmayan bir ana hesap yenilemesi, yeniden denemede başarılı olabileceği için hâlâ `Retry-After` ile `503` yanıtı verir. Mesaj artık kalıcı bir başarısızlığın ana hesabın yeniden kimlik doğrulaması gerektirdiğini de belirtiyor. + ## Yerel Codex'i geri yükleme `ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. 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 bfe76b0c72..92a3e94368 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 @@ -320,6 +320,12 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 后台重新验证是独立功能,默认关闭。它要求 Token Guardian、`openai` 的 `proactive` 刷新策略及 `tokenGuardian.codexWarmupEnabled`,并跳过等待注册验证的账号。 +### 账号停止处理请求的原因 + +账号退出账号池选择时,原因随判定一起传递,而不是为显示重新计算,因此界面不会在路由已排除该账号时仍显示正常。`GET /api/codex-auth/accounts` 在每个账号的 `needsReauth` 旁返回 `reauthReason`:从未保存凭据为 `missing_credential`,刷新持续失败为 `refresh_failed`,用量查询本身被拒绝为 `quota_unauthorized`。 + +主账号刷新未完成时仍返回带 `Retry-After` 的 `503`,因为重试仍可能成功。消息中现在补充说明:若持续失败,则主账号需要重新认证,而不只是再试一次。 + ## 恢复原生 Codex `ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 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 708341e5bf..4da96fe7f1 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 @@ -327,6 +327,12 @@ ocx service install # 常駐:登入時自動啟動,崩潰後自動重新 背景重新驗證是獨立功能,預設關閉。它需要 Token Guardian、`openai` 的 `proactive` 更新政策及 `tokenGuardian.codexWarmupEnabled`,並略過等待註冊驗證的帳號。 +### 帳號停止處理請求的原因 + +帳號退出帳號池選擇時,原因會隨判定一起傳遞,而不是為了顯示重新計算,因此介面不會在路由已排除該帳號時仍顯示正常。`GET /api/codex-auth/accounts` 會在每個帳號的 `needsReauth` 旁回傳 `reauthReason`:從未儲存憑證為 `missing_credential`,更新持續失敗為 `refresh_failed`,用量查詢本身遭拒為 `quota_unauthorized`。 + +主帳號更新未完成時仍回傳帶 `Retry-After` 的 `503`,因為重試仍可能成功。訊息現在補充說明:若持續失敗,代表主帳號需要重新認證,而不只是再試一次。 + ## 恢復原生 Codex `ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..2ab3271e2b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -382,6 +382,7 @@ "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", "codex-account-store.test.ts": "codex-integration", + "codex-account-unusable-reason.test.ts": "codex-integration", "codex-admission-primitives.test.ts": "codex-integration", "codex-admission.test.ts": "codex-integration", "codex-affinity-debug.test.ts": "codex-integration", diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 3001eb2b23..5427fed841 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -20,34 +20,70 @@ export interface CodexAccountUsabilityOptions { modelEligibleAccountIds?: ReadonlySet; } -export function isCodexAccountUsable( +/** + * Why an account was refused, in the order the checks run. This is the attribution half of + * selection: an operator whose model quietly disappeared needs to know that one account fell out + * and why, not merely that the pool got smaller (#4212). + */ +export type CodexAccountUnusableReason = + | "model_not_entitled" + | "main_hard_locked" + | "main_traffic_blocked" + | "legacy_pool_sentinel" + | "needs_reauth" + | "main_credential_unavailable" + | "not_in_pool" + | "missing_credential" + | "deleted" + | "validation_pending"; + +/** + * The single source of truth for both selection and its explanation. `isCodexAccountUsable()` is + * this function's boolean projection rather than a parallel copy of the same branches, so a reason + * can never claim an account is fine while routing drops it, or name a cause routing did not use. + */ +export function codexAccountUnusableReason( config: OcxConfig, accountId: string, options: CodexAccountUsabilityOptions = {}, -): boolean { - if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false; +): CodexAccountUnusableReason | undefined { + if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) { + return "model_not_entitled"; + } if (accountId === MAIN_CODEX_ACCOUNT_ID) { - if (isMainAccountHardLocked(config)) return false; + if (isMainAccountHardLocked(config)) return "main_hard_locked"; // Startup recovery owns the physical auth/vault boundary. Never parse or select // native __main__ while an encrypted switch journal is pending or inconclusive. - if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return false; + if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return "main_traffic_blocked"; // A legacy pool row with the sentinel makes an active `__main__` ambiguous. // Fail closed until the authenticated compatibility-delete path removes it. - if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false; - if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return false; + if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return "legacy_pool_sentinel"; + if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return "needs_reauth"; // A selection-only caller owns the recovery/drain fence and will reject main // before reservation or token materialization. Treat cached main as a routing // candidate without touching the credential file so affinity is not rebound. - if (options.nativeMainSelectionOnly) return true; + if (options.nativeMainSelectionOnly) return undefined; // Main account: a refresh grant is enough to route; materialization refreshes before I/O. - return options.isMainAccountTokenLive + const mainLive = options.isMainAccountTokenLive ? options.isMainAccountTokenLive() : isMainAccountCredentialUsable(); + return mainLive ? undefined : "main_credential_unavailable"; } const exists = (config.codexAccounts ?? []) .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); - if (!exists) return false; - if (isAccountNeedsReauth(accountId)) return false; + if (!exists) return "not_in_pool"; + if (isAccountNeedsReauth(accountId)) return "needs_reauth"; const record = readCodexAccountRecord(accountId); - return !!record?.credential && record.deletedAt == null && !record.codexValidationPending; + if (!record?.credential) return "missing_credential"; + if (record.deletedAt != null) return "deleted"; + if (record.codexValidationPending) return "validation_pending"; + return undefined; +} + +export function isCodexAccountUsable( + config: OcxConfig, + accountId: string, + options: CodexAccountUsabilityOptions = {}, +): boolean { + return codexAccountUnusableReason(config, accountId, options) === undefined; } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 79fca9470f..09becf51ea 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -364,6 +364,20 @@ function mainQuotaWithCarriedResetCredits( }; } +/** + * Why an account needs the operator. `missing_credential`, `refresh_failed`, and + * `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized` + * and `forbidden` exist because the shared health projection may return them; today + * `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union + * keeps this field correct if that projection widens rather than silently dropping a reason. + */ +export type CodexAccountReauthReason = + | "missing_credential" + | "refresh_failed" + | "quota_unauthorized" + | "unauthorized" + | "forbidden"; + function poolAccountDto( account: CodexAccount, quotaResult: PoolQuotaResult, @@ -374,8 +388,19 @@ function poolAccountDto( ): CodexAuthAccountDto { const plan = codexPlanValue(account.plan); const quota = quotaForPlan(quotaResult.quota, plan); - const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id); + const runtimeReauth = isAccountNeedsReauth(account.id); + const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth; const health = projectCodexAccountHealth({ accountId: account.id, needsReauth }); + // `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the + // health projection. Emitting only the boolean is what left #4212's reporter guessing which + // account took their model away and why, so name the cause they actually have to act on. + const reauthReason: CodexAccountReauthReason | undefined = !hasCredential + ? "missing_credential" + : runtimeReauth + ? "refresh_failed" + : quotaResult.needsReauth + ? "quota_unauthorized" + : health.status === "reauth_required" ? health.reason : undefined; return { id: account.id, email: projectEmail(account.email, maskEmails) ?? account.email, @@ -387,6 +412,7 @@ function poolAccountDto( priority, quota: quota ? { ...quota } : null, needsReauth: needsReauth || health.status === "reauth_required", + ...(reauthReason !== undefined ? { reauthReason } : {}), hasCredential, ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}), ...oauthAccountHealthFields("codex", account.id, health), @@ -1161,6 +1187,11 @@ export interface CodexAuthAccountDto { priority: number; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; + /** + * Which of the independent causes behind `needsReauth` fired. Present only when the account + * needs the operator; `/api/oauth/accounts` already carries the same field name. + */ + reauthReason?: CodexAccountReauthReason; hasCredential: boolean; health: OAuthAccountHealth; healthLabel: OAuthHealthLabel; @@ -2009,12 +2040,20 @@ export async function listCodexAuthAccountsSnapshot( const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked ? mainResult.hasCredential : getMainAccountCredentialPresence() ?? false; - const mainNeedsReauth = (mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential) - || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential; + const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const mainHealth = projectCodexAccountHealth({ accountId: MAIN_CODEX_ACCOUNT_ID, needsReauth: mainNeedsReauth, }); + // The main row carries the same attribution as a pool row. Reaching this point without + // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the + // cause is a refresh that did not complete. + const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential + ? "missing_credential" + : mainNeedsReauth + ? "refresh_failed" + : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; const main: CodexAuthAccountDto = { id: MAIN_CODEX_ACCOUNT_ID, email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login", @@ -2029,6 +2068,7 @@ export async function listCodexAuthAccountsSnapshot( priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, + ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}), quota: mainInfo.quota ? { ...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan), } : null, diff --git a/src/server/responses/codex-auth-error.ts b/src/server/responses/codex-auth-error.ts index 8d84c54392..8ae85e837b 100644 --- a/src/server/responses/codex-auth-error.ts +++ b/src/server/responses/codex-auth-error.ts @@ -29,10 +29,15 @@ export function nativeMainRefreshFailureResponse(error: unknown): Response { if (error instanceof MainAccountTokenRefreshError || error instanceof MainAuthJsonChangedDuringRefreshError || (error instanceof NativeProfileError && error.retryable)) { + // A bare "retry this request" reads as a transient server fault, which is how #4212's reporter + // concluded the proxy had broken while one account was the thing that needed them. The refusal + // stays a retryable 503 because the refresh genuinely may succeed, but it now names what is + // failing and what to do when retrying stops helping. const response = formatErrorResponse( 503, "server_busy", - "Codex main credential refresh did not complete; retry this request", + "Codex main credential refresh did not complete; retry this request. " + + "If it keeps failing, the main Codex account needs reauthentication.", ); const headers = new Headers(response.headers); headers.set("Retry-After", "1"); diff --git a/tests/codex-integration/codex-account-unusable-reason.test.ts b/tests/codex-integration/codex-account-unusable-reason.test.ts new file mode 100644 index 0000000000..fbb4c452fe --- /dev/null +++ b/tests/codex-integration/codex-account-unusable-reason.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + codexAccountUnusableReason, + isCodexAccountUsable, + type CodexAccountUnusableReason, +} from "../../src/codex/account-usability"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { MAIN_CODEX_ACCOUNT_ID, MainAccountTokenRefreshError } from "../../src/codex/main-account"; +import { nativeMainRefreshFailureResponse } from "../../src/server/responses/codex-auth-error"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const STORE_DIR = join(import.meta.dir, ".tmp-unusable-reason-store"); +const CODEX_DIR = join(import.meta.dir, ".tmp-unusable-reason-codex"); +let prevOpencodexHome: string | undefined; +let prevCodexHome: string | undefined; + +function writeMainAuth(): void { + mkdirSync(CODEX_DIR, { recursive: true }); + writeFileSync( + join(CODEX_DIR, "auth.json"), + JSON.stringify({ tokens: { access_token: "main_access", account_id: "main_acct" } }), + ); +} + +function saveCred(id: string): void { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); +} + +function makeConfig(): OcxConfig { + return { + providers: {}, + codexAccounts: [ + { id: "paid", email: "paid@test", isMain: false }, + { id: "stuck", email: "stuck@test", isMain: false }, + { id: "uncredentialed", email: "none@test", isMain: false }, + ], + activeCodexAccountId: "paid", + } as OcxConfig; +} + +const ACCOUNT_IDS = ["paid", "stuck", "uncredentialed", MAIN_CODEX_ACCOUNT_ID]; + +describe("codex account unusable reason", () => { + beforeEach(() => { + prevOpencodexHome = process.env.OPENCODEX_HOME; + prevCodexHome = process.env.CODEX_HOME; + for (const dir of [STORE_DIR, CODEX_DIR]) if (existsSync(dir)) removeTreeWithRetry(dir); + mkdirSync(STORE_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = STORE_DIR; + process.env.CODEX_HOME = CODEX_DIR; + for (const id of ACCOUNT_IDS) clearAccountNeedsReauth(id); + saveCred("paid"); + saveCred("stuck"); + writeMainAuth(); + }); + + afterEach(() => { + for (const id of ACCOUNT_IDS) clearAccountNeedsReauth(id); + for (const dir of [STORE_DIR, CODEX_DIR]) if (existsSync(dir)) removeTreeWithRetry(dir); + if (prevOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevOpencodexHome; + if (prevCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevCodexHome; + }); + + test("a healthy pool account reports no reason", () => { + expect(codexAccountUnusableReason(makeConfig(), "paid")).toBeUndefined(); + }); + + test("an account stuck on a failed credential refresh names itself", () => { + // The #4212 case: routing drops the account and, before this, said nothing about why. + markAccountNeedsReauth("stuck"); + expect(codexAccountUnusableReason(makeConfig(), "stuck")).toBe("needs_reauth"); + }); + + test("a pool row without a stored credential is distinguishable from a failed refresh", () => { + expect(codexAccountUnusableReason(makeConfig(), "uncredentialed")).toBe("missing_credential"); + }); + + test("an id that is not a pool row reports not_in_pool", () => { + expect(codexAccountUnusableReason(makeConfig(), "never-added")).toBe("not_in_pool"); + }); + + test("an account outside a gated model's entitled set reports model_not_entitled", () => { + const reason = codexAccountUnusableReason(makeConfig(), "paid", { + modelEligibleAccountIds: new Set(["stuck"]), + }); + expect(reason).toBe("model_not_entitled"); + }); + + test("the main account without a native credential reports main_credential_unavailable", () => { + rmSync(join(CODEX_DIR, "auth.json")); + expect(codexAccountUnusableReason(makeConfig(), MAIN_CODEX_ACCOUNT_ID)) + .toBe("main_credential_unavailable"); + }); + + test("the boolean projection never disagrees with the reason", () => { + // isCodexAccountUsable() is defined as this function's projection rather than a second copy of + // the same branches, so an account can never be refused for a cause no surface can name. + markAccountNeedsReauth("stuck"); + const config = makeConfig(); + const cases: { id: string; expected: CodexAccountUnusableReason | undefined }[] = [ + { id: "paid", expected: undefined }, + { id: "stuck", expected: "needs_reauth" }, + { id: "uncredentialed", expected: "missing_credential" }, + { id: "never-added", expected: "not_in_pool" }, + { id: MAIN_CODEX_ACCOUNT_ID, expected: undefined }, + ]; + for (const { id, expected } of cases) { + const reason = codexAccountUnusableReason(config, id); + expect(reason).toBe(expected as CodexAccountUnusableReason); + expect(isCodexAccountUsable(config, id)).toBe(reason === undefined); + } + }); +}); + +describe("native main refresh refusal", () => { + test("a retryable refresh failure stays a 503 but names the account and the action", async () => { + const response = nativeMainRefreshFailureResponse(new MainAccountTokenRefreshError("transient")); + expect(response.status).toBe(503); + expect(response.headers.get("Retry-After")).toBe("1"); + const message = ((await response.json()) as { error: { message: string } }).error.message; + expect(message).toContain("Codex main credential refresh did not complete"); + expect(message).toContain("reauthentication"); + }); + + test("a terminal reauth failure still refuses with 401 rather than a retry promise", async () => { + const response = nativeMainRefreshFailureResponse(new MainAccountTokenRefreshError("reauth")); + expect(response.status).toBe(401); + const message = ((await response.json()) as { error: { message: string } }).error.message; + expect(message).toBe("Codex main account needs reauthentication"); + }); +}); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index ba80be3422..89813bb058 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -5281,10 +5281,15 @@ describe("codex-auth API", () => { if (restart) clearAccountNeedsReauth(accountId); const rows = await listCodexAuthAccounts(config, false); const authFailed = !replace && (status === 401 || status === 403); - expect(rows.find(row => row.id === accountId)).toMatchObject({ + const row = rows.find(entry => entry.id === accountId); + expect(row).toMatchObject({ needsReauth: authFailed, health: { status: authFailed ? "reauth_required" : "warning", reason: authFailed ? "refresh_failed" : "validation_pending" }, }); + // The reason travels with the state, so an operator reading the account surface can tell a + // failed refresh from a pending validation without inferring it from `health` (#4212). + if (authFailed) expect(row).toMatchObject({ reauthReason: "refresh_failed" }); + else expect(row).not.toHaveProperty("reauthReason"); fail = false; await refresh(); expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBeUndefined(); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..2c2c04c2e4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -217,6 +217,7 @@ "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", "codex-account-store.test.ts": "codex-integration", + "codex-account-unusable-reason.test.ts": "codex-integration", "codex-admission-primitives.test.ts": "codex-integration", "codex-admission.test.ts": "codex-integration", "codex-affinity-debug.test.ts": "codex-integration", From dfe2b5ee5051ae68338a31dbe43b5ef00031e935 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 07:54:04 +0900 Subject: [PATCH 043/126] docs(devlog): record the L3 WP2 attribution unit and its two out-of-scope follow-ups --- .../020_wp2_4212_attribution.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 devlog/_plan/260911_l3_account_pool/020_wp2_4212_attribution.md diff --git a/devlog/_plan/260911_l3_account_pool/020_wp2_4212_attribution.md b/devlog/_plan/260911_l3_account_pool/020_wp2_4212_attribution.md new file mode 100644 index 0000000000..63bf52d267 --- /dev/null +++ b/devlog/_plan/260911_l3_account_pool/020_wp2_4212_attribution.md @@ -0,0 +1,73 @@ +# WP2 — #4212 a pool account stuck on a failed credential refresh drops its models without naming itself + +## Scope, and why this is `Refs` rather than `Closes` + +The reporter saw two things: gated models vanished from the model list, and requests failed with a +generic 503. Neither publisher is L3's. + +- The 503 they quoted is inlined at `src/server/responses/core.ts:2336` and `compact.ts:383`. L1 owns + both. Recorded as a follow-up. +- The model-list drop is published from `src/codex/catalog/sync.ts:1777`. Out of scope. Recorded as a + follow-up. + +What L3 owns is the layer underneath: the decision that removes the account, and the surfaces that +describe it. Packet decision, followed as written. + +## The actual defect + +The issue names it precisely: `isAccountNeedsReauth(accountId)` makes the account unselectable +"with no reason carried to callers". `isCodexAccountUsable()` returned a bare boolean, so every +surface that wanted to explain a refusal had to re-derive the cause from a different source. That is +how a surface ends up reporting an account healthy while routing is dropping it. + +So the reason now comes from the same function as the decision. `codexAccountUnusableReason()` holds +every branch and returns the cause; `isCodexAccountUsable()` is its boolean projection rather than a +second copy. A reason cannot name a cause routing did not use, and routing cannot refuse an account +for a cause no surface can name. + +That refactor is the risky part of this change — `isCodexAccountUsable` is called from routing, +auth-context, sidecar auth, and subagent fallback — so it was audited for exact equivalence rather +than reviewed by eye. See below. + +## Attribution on the account surface + +`poolAccountDto` computed `needsReauth` as an OR of three independent causes plus a persisted +verdict resolved inside the health projection, and emitted only the boolean. It now also emits +`reauthReason`: `missing_credential` for a credential that was never stored, `refresh_failed` for a +refresh that keeps failing — the reporter's case — and `quota_unauthorized` when the usage lookup +itself was rejected. `/api/oauth/accounts` already carried that field name, so the Codex account +surface now matches its sibling. The main row carries it too, so the field's contract holds for +every row rather than only pool rows. + +## The refusal string + +`nativeMainRefreshFailureResponse` said "retry this request" and nothing else, which is how the +reporter concluded the proxy had broken. It stays a retryable 503 with `Retry-After`, because the +refresh genuinely may succeed, and now adds that a failure which persists means the main account +needs reauthentication. + +The pool-account 401 was deliberately left alone. `tests/server/server-search.test.ts:344` asserts +that message must not contain the account id, alias, or email — naming the account there is a +privacy decision this repository already made against, and it is not L3's to reverse. + +## Audit + +Three read-only `xai/grok-4.6` subagents, in parallel. + +- **Equivalence (pass).** No input changes the truth value, helper call count, call order, or throw + set. `readCodexAccountRecord` is still called exactly once and only after the existence and reauth + checks; the `isMainAccountTokenLive` seam still fires 0 or 1 times, not 2; the expanded pool tail + is truth-equivalent for a null record, a record without a credential, `deletedAt` set, and + `codexValidationPending`. +- **DTO and error layer (pass, 5 non-blocking findings).** Three were folded in: the main row now + carries `reauthReason`, the union comment no longer overclaims what the current health projection + can produce, and the DTO-layer assertion was added to the existing refresh test. Two were recorded + rather than fixed: the request-path 401 (privacy, above) and the GUI not yet reading the field. +- **Re-audit after fold-in (pass).** Confirmed the main-row `||` still short-circuits so the + stale-generation cleanup call count is unchanged, that the main row cannot emit a reason without + the boolean or the reverse, and that all eight locale inserts are localized and correctly placed. + +## Verification + +Local suite, typecheck, and build: NOT RUN by operator instruction. Hosted CI on the pushed head is +the evidence. From 785a3a2985e18b3537aa58eae6ffab22a1806805 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:01:26 +0900 Subject: [PATCH 044/126] fix(qoder): refuse vendor CLI scaffolding in routed output The qoder route is contractually a text and reasoning surface: the CLI is launched with its own tools and MCP servers disabled and Codex owns tool control. The vendor CLI does not always honour that. An MCP lazy-loading enumerating the operator's configured MCP servers, and tool-call markup opened as and closed as , have both reached the client as assistant text, because the shared stream-json parser filters frame types and forwards a text delta's contents unread. Add a streaming scaffolding filter and wrap the emit callback the qoder adapter hands to runCodingAgentTurn. A complete block is recognizable and self-delimiting, so it is removed and the answer around it survives. Anything else carrying a scaffolding signature is not repairable by guesswork, so the turn fails closed with an error that names the marker class and nothing else -- echoing the offending text back would publish the server inventory this guard exists to contain. Markers can be split across deltas, so a tail that is still a possible marker prefix is held and released on flush. The terminal event flushes first: an answer swallowed in full and followed by done would otherwise reach the empty-completion guard as a successful but empty turn rather than a refusal. The suppressed block is discarded as it arrives, retaining only enough trailing bytes to spot a split closer, with a 64 KiB ceiling on how much one unterminated block may swallow before the turn is refused. Closes #4190 --- .../020_4190_qoder_scaffold_guard.md | 97 ++++++++++ scripts/test-layout/layout.json | 1 + src/adapters/qoder/adapter.ts | 70 ++++++- src/adapters/qoder/scaffold-guard.ts | 173 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/providers/qoder-scaffold-guard.test.ts | 155 ++++++++++++++++ 6 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md create mode 100644 src/adapters/qoder/scaffold-guard.ts create mode 100644 tests/providers/qoder-scaffold-guard.test.ts diff --git a/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md b/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md new file mode 100644 index 0000000000..f24b36fd9b --- /dev/null +++ b/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md @@ -0,0 +1,97 @@ +# 020 — #4190: refuse vendor scaffolding on the qoder route + +Unit: `devlog/_plan/260911_l6_streaming_tools`. Lane L6, work-phase 2. +Issue: [#4190](https://github.com/lidge-jun/opencodex/issues/4190). + +## The contract that was being broken + +The qoder route is documented as a text and reasoning surface. `buildQoderArgs` launches the +CLI with `--tools "" --strict-mcp-config --setting-sources "" --max-turns 1 +--no-session-persistence`, and both `coding-agent/protocol.ts` and `coding-agent/turn.ts` +state that Codex retains tool control and vendor tools are never invoked. + +The reporter saw two things reach the client as assistant text anyway: an MCP lazy-loading +`` block enumerating the local machine's configured MCP servers from +`~/.qoder/mcp.json` and from plugins, and vendor tool-call markup opened as +`` and closed as `` — mismatched, which is what a model emitting +remembered markup looks like rather than a serializer's output. + +The proxy-side hole is one line of trust. `mapRawStreamEvent` forwards a `text_delta` +verbatim; frame *types* are filtered, frame *contents* are not. So whatever the vendor's agent +layer puts in the text channel is relayed, and the strongest version of this leak publishes +the operator's MCP server inventory to whoever is reading the turn. + +## What was implemented + +`src/adapters/qoder/scaffold-guard.ts`, a streaming filter, and `guardQoderScaffolding` in +the adapter, which wraps the `emit` callback handed to `runCodingAgentTurn`. That wrapper is +the last qoder-specific point in the path, which is why the guard sits there rather than in +the parser every coding-agent CLI shares — CodeBuddy runs the same turn code and is not part +of this report. + +Two shapes, two answers, per the lane packet's recorded decision: + +- A complete `` block is recognizable and + self-delimiting. It is removed and the answer around it survives. +- Anything else carrying a scaffolding signature — ``, or a `` with no opener — fails the turn closed. A partial + tool-call block has no reliable end, and the prose around it may be the vendor's own agent + narration rather than the model's answer, so repairing it would be guesswork. + +It is a stream filter, not a regex over a finished string. A marker can be split across +deltas, so a tail that is still a possible marker prefix is held rather than emitted, and the +terminal event flushes both channels first. That flush is load-bearing in a way that is not +obvious: `isContentEvent` in `empty-completion-guard.ts` counts only non-empty +`text_delta`s as content, so an answer swallowed in full and followed by `done` would reach +the client as a successful but empty turn instead of as the refusal it is. + +The suppressed block is discarded as it arrives; only the trailing bytes needed to spot a +split closer are kept, so an unclosed reminder cannot grow memory. A 64 KiB ceiling bounds +how much of a turn one unterminated block may swallow before the turn is refused. + +## Decisions this issue left open + +**Qoder only, not the shared coding-agent path.** The lane owns `src/adapters/qoder/` and +not `src/adapters/coding-agent/protocol.ts`, and the leak is reported only for Qoder. If +CodeBuddy turns out to do the same thing, the filter is a pure module and lifting it is a +small change — but it should be driven by a report, not by symmetry. + +**Fail closed rather than strip, for tool-call markup.** The issue's own review lists both +options. Silently deleting markup leaves the user with a mutilated answer and no signal that +the route's contract was violated; the error names the marker class and says why. + +**Not retryable.** The leak is intermittent, so a retry would often succeed. It is still +marked `retryable: false`: an automatic retry spends the operator's vendor credits on a +contract violation the proxy cannot influence, and hiding an intermittent violation is how it +stays unfixed. + +**Vendor tool-call frames are left alone.** `mapRawStreamEvent` maps a `tool_use` block to +`tool_call_start`. That is arguably also a contract violation, but it is a typed frame rather +than leaked text, the issue reports the text channel, and `protocol.ts` documents that seam +as deliberately prepared for a future tool bridge. + +**Known false positive.** A turn that legitimately discusses `` or +`` syntax will be stripped or refused. That is the cost of failing closed on a +route whose leak publishes the operator's MCP inventory, and it is the direction the packet +recorded. + +**Not touched: making the CLI actually run with MCP disabled.** The issue's first suggested +direction is to fix the spawn so the vendor agent layer never initializes. The installed +`@qoder-ai/qodercli` bundle still contains the reminder builder and appears to initialize it +despite the flags, so that fix lives in the vendor, not here. This guard is the containment +that does not depend on the vendor agreeing. + +## Verification + +Focused regression test at `tests/providers/qoder-scaffold-guard.test.ts`, beside the +existing `qoder-adapter.test.ts`, registered in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. It covers block removal, a marker split across +three deltas, the held tail released on flush, fail-closed on tool-call markup and on a stray +closer, the unterminated-block case, the latch, and the wrapper's terminal handling including +the flush-before-`done` rule and forwarding a vendor error rather than replacing it. Two +cases assert that neither the leaked server list nor the leaked shell command appears in the +refusal message. + +Local suite, typecheck and build: NOT RUN, by operator instruction for this dispatch round. +Hosted CI on the pushed head is the evidence. + diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cee1514e55..4056d8bc26 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -997,6 +997,7 @@ "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", + "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index 20c0b5581b..1bb8821b0b 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn"; import { QODER_PROFILES, type QoderProfile } from "./profiles"; +import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard"; export type QoderAdapterDeps = CodingAgentDeps; @@ -31,6 +32,73 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo return args; } +/** + * Wrap the turn's outbound channel with the scaffolding guard (#4190). + * + * The vendor CLI can put its own agent layer into the text channel despite being launched + * with tools and MCP disabled, and the shared stream-json parser forwards a text delta + * without inspecting it. This is the last point that is still qoder-specific, so the guard + * sits here rather than in the parser every coding-agent CLI shares. + * + * A terminal event flushes both channels first. The held tail is text the filter could not + * yet prove was not the start of a marker; dropping it would truncate a legitimate answer, + * and swallowing an entire response before forwarding a "done" reads downstream as an empty + * completion rather than as the refusal it is. + */ +export function guardQoderScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + const textFilter = new QoderScaffoldFilter(); + const thinkingFilter = new QoderScaffoldFilter(); + let closed = false; + + const refuse = (reason: string): void => { + if (closed) return; + closed = true; + emit({ + type: "error", + message: qoderScaffoldErrorMessage(reason), + status: 502, + errorType: "upstream_error", + code: QODER_SCAFFOLD_ERROR_CODE, + // Intermittent, but a silent retry spends the operator's vendor credits on a + // contract violation the proxy cannot influence. Surface it instead. + retryable: false, + }); + }; + + return (event: AdapterEvent): void => { + if (closed) return; + if (event.type === "text_delta") { + const cleaned = textFilter.push(event.text); + if (cleaned.text) emit({ ...event, text: cleaned.text }); + if (cleaned.fail) refuse(cleaned.fail); + return; + } + if (event.type === "thinking_delta") { + const cleaned = thinkingFilter.push(event.thinking); + if (cleaned.text) emit({ ...event, thinking: cleaned.text }); + if (cleaned.fail) refuse(cleaned.fail); + return; + } + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + const tail = textFilter.flush(); + const reasoning = thinkingFilter.flush(); + if (tail.text) emit({ type: "text_delta", text: tail.text }); + if (reasoning.text) emit({ type: "thinking_delta", thinking: reasoning.text }); + const fail = tail.fail ?? reasoning.fail; + // A vendor error already carries the better explanation for why the turn ended; + // only a terminal that claims success is replaced. + if (fail && event.type !== "error") { + refuse(fail); + return; + } + closed = true; + emit(event); + return; + } + emit(event); + }; +} + export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapterDeps = {}): ProviderAdapter { return { name: "qoder", @@ -60,7 +128,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt provider, parsed, incoming, - emit, + emit: guardQoderScaffolding(emit), buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov), buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey), deps, diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts new file mode 100644 index 0000000000..88c93d3e6d --- /dev/null +++ b/src/adapters/qoder/scaffold-guard.ts @@ -0,0 +1,173 @@ +/** + * Vendor-scaffolding guard for the qoder route (#4190). + * + * The qoder route is contractually a text and reasoning surface: the CLI is spawned with + * `--tools "" --strict-mcp-config --setting-sources ""`, and Codex keeps tool ownership. + * The vendor CLI does not always honour that. It has been observed emitting its own agent + * layer into the assistant text channel — an MCP lazy-loading `` block + * listing the local machine's configured MCP servers, and framework tool-call markup with a + * mismatched closer. Both reached the client verbatim, because the shared stream-json parser + * forwards a text delta without inspecting it. + * + * Two shapes, two answers. A complete `` block is recognizable and + * self-delimiting, so it is removed and the surrounding answer survives. Anything else that + * carries a scaffolding signature is not repairable by guesswork — a partial tool-call block + * has no reliable end, and the text around it may already be the vendor's own agent + * narration rather than the model's answer — so the turn fails closed instead. + * + * The filter is a stream, not a regex over a finished string: a marker can be split across + * deltas, so a tail that is still a possible marker prefix is held back rather than emitted. + * Callers must therefore `flush()` before forwarding a terminal event, or a legitimate + * answer ending in "<" would lose its last character. + */ + +/** Error code for a turn refused because vendor scaffolding reached the text channel. */ +export const QODER_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected"; + +const REMINDER_OPEN = "` opened and `` closed — mismatched, which is what + * a model emitting remembered markup looks like, and exactly why reconstructing the intended + * text is not possible. A stray `` with no opener is in the same class: + * the block it belonged to was already partly forwarded, or never existed. + */ +const UNREPAIRABLE_MARKERS = ["", REMINDER_CLOSE] as const; + +/** Every marker the scanner must be able to recognize mid-split. */ +const ALL_MARKERS = [REMINDER_OPEN, ...UNREPAIRABLE_MARKERS] as const; + +const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length)); + +/** + * Ceiling on a suppressed block before it is treated as unterminated. + * + * The block itself is discarded as it arrives, so this is not a memory bound — only the + * trailing bytes needed to spot a split closer are retained. It bounds how much of a turn a + * single unclosed reminder is allowed to swallow silently before the turn is refused. + */ +const MAX_SUPPRESSED_CHARS = 64 * 1024; + +/** Result of feeding one chunk: the text safe to forward, and a refusal reason once tripped. */ +export interface ScaffoldFilterResult { + /** Text cleared for the client. Empty when everything in the chunk was held or dropped. */ + text: string; + /** Non-null exactly once, on the chunk that trips the guard. */ + fail: string | null; +} + +/** Longest suffix of `text` that could still grow into one of the markers. */ +function heldSuffixLength(text: string): number { + const limit = Math.min(MAX_MARKER_LENGTH - 1, text.length); + for (let length = limit; length > 0; length--) { + const suffix = text.slice(text.length - length).toLowerCase(); + for (const marker of ALL_MARKERS) { + if (marker.length > length && marker.startsWith(suffix)) return length; + } + } + return 0; +} + +/** + * Streaming scaffolding filter for one channel (text or reasoning). + * + * One instance per channel: the two never share suppression state, so a reminder block + * opened in reasoning cannot swallow the answer text. + */ +export class QoderScaffoldFilter { + private mode: "pass" | "suppress" = "pass"; + private pending = ""; + private suppressedTail = ""; + private suppressedChars = 0; + private failed = false; + + push(chunk: string): ScaffoldFilterResult { + if (this.failed || !chunk) return { text: "", fail: null }; + let cleared = ""; + let buffer = this.mode === "pass" ? this.pending + chunk : chunk; + this.pending = ""; + + for (;;) { + if (this.mode === "suppress") { + const scan = this.suppressedTail + buffer; + const close = scan.toLowerCase().indexOf(REMINDER_CLOSE); + if (close < 0) { + this.suppressedChars += buffer.length; + if (this.suppressedChars > MAX_SUPPRESSED_CHARS) { + return this.fail(cleared, `an unterminated ${REMINDER_OPEN}> block`); + } + // The block is discarded as it arrives; only enough tail to spot a split closer is kept. + this.suppressedTail = scan.slice(Math.max(0, scan.length - (REMINDER_CLOSE.length - 1))); + return { text: cleared, fail: null }; + } + buffer = scan.slice(close + REMINDER_CLOSE.length); + this.mode = "pass"; + this.suppressedTail = ""; + this.suppressedChars = 0; + continue; + } + + let earliest = -1; + let found = ""; + const lowered = buffer.toLowerCase(); + for (const marker of ALL_MARKERS) { + const at = lowered.indexOf(marker); + if (at < 0) continue; + // A closer sitting exactly where an opener starts cannot happen, so ties are impossible. + if (earliest < 0 || at < earliest) { + earliest = at; + found = marker; + } + } + + if (earliest < 0) { + const held = heldSuffixLength(buffer); + cleared += held > 0 ? buffer.slice(0, buffer.length - held) : buffer; + this.pending = held > 0 ? buffer.slice(buffer.length - held) : ""; + return { text: cleared, fail: null }; + } + + // Text produced before the scaffolding is the model's own answer; it is kept either way. + cleared += buffer.slice(0, earliest); + if (found !== REMINDER_OPEN) return this.fail(cleared, `vendor tool-call markup (${found})`); + this.mode = "suppress"; + this.suppressedTail = ""; + this.suppressedChars = 0; + buffer = buffer.slice(earliest + REMINDER_OPEN.length); + } + } + + /** Release the held tail. Call before forwarding a terminal event, never mid-stream. */ + flush(): ScaffoldFilterResult { + if (this.failed) return { text: "", fail: null }; + if (this.mode === "suppress") return this.fail("", `an unterminated ${REMINDER_OPEN}> block`); + const text = this.pending; + this.pending = ""; + return { text, fail: null }; + } + + private fail(cleared: string, reason: string): ScaffoldFilterResult { + this.failed = true; + this.pending = ""; + this.suppressedTail = ""; + return { text: cleared, fail: reason }; + } +} + +/** + * Message for a refused turn. + * + * It names the marker class and nothing else. The leaked reminder in the report enumerated + * the operator's own MCP servers, so echoing the offending text back — into an error the + * client renders, and that a user may paste into an issue — would publish the thing this + * guard exists to contain. + */ +export function qoderScaffoldErrorMessage(reason: string): string { + return `Qoder CLI emitted ${reason} in the assistant text channel. This route runs the CLI with` + + " its own tools and MCP servers disabled and Codex owns tool control, so the turn was refused" + + " rather than forwarding vendor agent scaffolding to the client."; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 486a6de544..ba4339c178 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -832,6 +832,7 @@ "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", + "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", diff --git a/tests/providers/qoder-scaffold-guard.test.ts b/tests/providers/qoder-scaffold-guard.test.ts new file mode 100644 index 0000000000..21ac6789d5 --- /dev/null +++ b/tests/providers/qoder-scaffold-guard.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; +import type { AdapterEvent } from "../../src/types"; +import { guardQoderScaffolding } from "../../src/adapters/qoder/adapter"; +import { + QoderScaffoldFilter, + QODER_SCAFFOLD_ERROR_CODE, +} from "../../src/adapters/qoder/scaffold-guard"; + +/** + * #4190: the qoder route is documented as a text and reasoning surface with the vendor CLI's + * own tools and MCP servers disabled, yet an MCP lazy-loading reminder listing the operator's + * configured servers, and vendor tool-call markup with a mismatched closer, reached the + * client as assistant text. + */ + +const REMINDER = "MCP lazy-loading is active.\n## Connected MCP servers\n" + + "- internal-notes\n- deploy-keys\nUse mcp_list / mcp_get / mcp_call."; + +const TOOL_MARKUP = "\ncd /srv/private && git status\n"; + +function collect(): { events: AdapterEvent[]; emit: (event: AdapterEvent) => void } { + const events: AdapterEvent[] = []; + return { events, emit: event => { events.push(event); } }; +} + +function textOf(events: AdapterEvent[]): string { + return events.map(event => (event.type === "text_delta" ? event.text : "")).join(""); +} + +describe("QoderScaffoldFilter", () => { + test("removes a complete reminder block and keeps the answer around it", () => { + const filter = new QoderScaffoldFilter(); + const first = filter.push(`Before.${REMINDER}After.`); + expect(first.fail).toBeNull(); + expect(first.text + filter.flush().text).toBe("Before.After."); + }); + + test("catches a marker split across deltas", () => { + const filter = new QoderScaffoldFilter(); + // The opening tag arrives in three pieces; a per-delta scan would miss it entirely. + const parts = ["Answer. secret server list Done."]; + const out = parts.map(part => filter.push(part)); + expect(out.every(result => result.fail === null)).toBe(true); + expect(out.map(result => result.text).join("") + filter.flush().text).toBe("Answer. Done."); + expect(out.map(result => result.text).join("")).not.toContain("secret server list"); + }); + + test("releases a held tail that never became a marker", () => { + const filter = new QoderScaffoldFilter(); + // "<" is a live marker prefix, so it cannot be forwarded until the stream ends. + const pushed = filter.push("compare a < b and a { + const filter = new QoderScaffoldFilter(); + const result = filter.push(`Checking the repositories.\n${TOOL_MARKUP}`); + expect(result.text).toBe("Checking the repositories.\n"); + expect(result.fail).toContain(" { + // The block it belonged to was already partly forwarded, or never existed. + expect(new QoderScaffoldFilter().push("tail").fail).toContain(""); + }); + + test("fails closed when a reminder is never terminated", () => { + const filter = new QoderScaffoldFilter(); + expect(filter.push("ok listing servers").fail).toBeNull(); + expect(filter.flush().fail).toContain("unterminated"); + }); + + test("latches: nothing more escapes after the guard trips", () => { + const filter = new QoderScaffoldFilter(); + expect(filter.push(TOOL_MARKUP).fail).not.toBeNull(); + expect(filter.push("more vendor narration")).toEqual({ text: "", fail: null }); + expect(filter.flush()).toEqual({ text: "", fail: null }); + }); +}); + +describe("guardQoderScaffolding", () => { + test("strips the reminder and still completes the turn", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "text_delta", text: `Here is the status.${REMINDER}` }); + guarded({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("Here is the status."); + expect(textOf(events)).not.toContain("mcp_call"); + expect(events[events.length - 1]!.type).toBe("done"); + }); + + test("flushes the held tail before the terminal event", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + // Without the flush this answer would arrive truncated, and an answer that is entirely + // held back would reach the empty-completion guard as a successful but empty turn. + guarded({ type: "text_delta", text: "1 < 2" }); + guarded({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("1 < 2"); + expect(events[events.length - 1]!.type).toBe("done"); + }); + + test("refuses the turn when tool-call markup leaks, and swallows the vendor's success", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "text_delta", text: `Checking.\n${TOOL_MARKUP}` }); + guarded({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("Checking.\n"); + const terminal = events[events.length - 1]!; + expect(terminal.type).toBe("error"); + if (terminal.type !== "error") throw new Error("expected an error terminal"); + expect(terminal.code).toBe(QODER_SCAFFOLD_ERROR_CODE); + expect(terminal.status).toBe(502); + expect(terminal.retryable).toBe(false); + expect(terminal.message).not.toContain("git status"); + expect(terminal.message).not.toContain("mcp_call"); + expect(events.filter(event => event.type === "done")).toHaveLength(0); + }); + + test("guards the reasoning channel independently of the text channel", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "thinking_delta", thinking: `Planning.${REMINDER}Continue.` }); + guarded({ type: "text_delta", text: "Answer." }); + guarded({ type: "done", stopReason: "stop" }); + const thinking = events.filter(event => event.type === "thinking_delta") + .map(event => event.type === "thinking_delta" ? event.thinking : "").join(""); + expect(thinking).toBe("Planning.Continue."); + expect(textOf(events)).toBe("Answer."); + }); + + test("forwards the vendor's own error rather than replacing it", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "text_delta", text: "partial never closed" }); + guarded({ type: "error", message: "Qoder CLI exited with code 118", status: 429 }); + const terminal = events[events.length - 1]!; + expect(terminal.type).toBe("error"); + if (terminal.type !== "error") throw new Error("expected an error terminal"); + // The vendor said why the turn ended; the guard's job here was only to drop the block. + expect(terminal.message).toBe("Qoder CLI exited with code 118"); + expect(textOf(events)).toBe("partial "); + }); + + test("passes unrelated events through untouched", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "tool_call_start", id: "call_1", name: "exec" }); + guarded({ type: "done", stopReason: "stop" }); + expect(events.map(event => event.type)).toEqual(["tool_call_start", "done"]); + }); +}); From 848adbb81e316c06bac4872ebecc5cfb02a6727d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:04:39 +0900 Subject: [PATCH 045/126] fix(update): key the Bun size gate on the directory, as before the carry Splitting the verifier moved the Bun size gate onto the dependency lookup, which requires node_modules/bun/package.json. The pre-carry gate keyed on the directory alone. Sentinels are the bun/zod subset of the declared dependencies when that subset is non-empty, so a manifest declaring zod but not bun leaves bun out of the sentinel loop entirely: an interrupted extraction that left a truncated binary and no manifest would then ride through and the tree would be called healthy. Verified against origin/dev's own module on the same fixture: both reject with "bundled Bun binary missing or truncated (< 10MB)". Removing the fallback accepts it, so the new test is not vacuous. --- src/update/transactional-install.mjs | 13 ++++++++++++- tests/update/update-tree-ownership.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs index d08cc4ebb3..76c6acd29a 100644 --- a/src/update/transactional-install.mjs +++ b/src/update/transactional-install.mjs @@ -33,6 +33,17 @@ function candidateTreeDependencyDir(packageDir, name) { return existsSync(join(dir, "package.json")) ? dir : undefined; } +/** + * The candidate's own bun directory, whether or not it carries a readable package.json. + * The size gate keys on the DIRECTORY, matching the pre-carry verifier: a half-extracted + * node_modules/bun holding a truncated binary and no manifest is still a broken tree, and + * bun is not always among the sentinels, so the sentinel loop cannot be relied on to catch it. + */ +function ownTreeBunDir(packageDir) { + const dir = join(packageDir, "node_modules", "bun"); + return existsSync(dir) ? dir : undefined; +} + /** The node_modules directory a package sits directly inside, or undefined. */ function enclosingNodeModules(packageDir) { const parent = dirname(packageDir); @@ -108,7 +119,7 @@ function verifyTreeWithDependencyLookup(packageDir, expectedVersion, dependencyD // The bundled Bun binary is the load-bearing artifact: without it the launcher exits // before serving anything, and a boot probe that called this tree healthy would reap // the only backup (review High 3). Size-gate the real binary, not just its package.json. - const bunPkgDir = dependencyDir(packageDir, "bun"); + const bunPkgDir = dependencyDir(packageDir, "bun") ?? ownTreeBunDir(packageDir); if (bunPkgDir) { const bunBinary = findLargestFile(bunPkgDir); if (!bunBinary || bunBinary.size < 10 * 1024 * 1024) { diff --git a/tests/update/update-tree-ownership.test.ts b/tests/update/update-tree-ownership.test.ts index f0f43c85e2..40d829fa9d 100644 --- a/tests/update/update-tree-ownership.test.ts +++ b/tests/update/update-tree-ownership.test.ts @@ -89,6 +89,28 @@ describe("#4202 install-tree dependency ownership", () => { expect(verifyInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); }); + test("a half-extracted Bun directory is size-gated even when Bun is not a sentinel", () => { + // Sentinels are the bun/zod subset when it is non-empty, so a manifest that declares + // zod but not bun leaves bun out of the sentinel loop entirely. The size gate has to + // key on the directory, as it did before the pnpm carry, or a truncated binary with no + // package.json rides through and the tree is called healthy. + const packageDir = join(root, "lib", "node_modules", ...PKG.split("/")); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ + name: PKG, version: "2.0.0", dependencies: { zod: "1" }, + })); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048)); + writeDependency(join(packageDir, "node_modules", "zod"), "zod"); + // Interrupted extraction: the binary landed, the manifest did not. + mkdirSync(join(packageDir, "node_modules", "bun"), { recursive: true }); + writeFileSync(join(packageDir, "node_modules", "bun", "bun.exe"), Buffer.alloc(1024)); + + const result = verifyInstallTree(packageDir, "2.0.0"); + + expect(result.ok).toBe(false); + expect(result.failures).toContain("bundled Bun binary missing or truncated (< 10MB)"); + }); + test("boot restore keeps the backup when the live tree only resolves through an ancestor", () => { // Live tree in a global npm layout, its dependencies supplied only by the sibling install. const globalRoot = join(root, "lib", "node_modules"); From de1d887395c628fce1ef933a1a6b742d5264cf1e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:22:56 +0900 Subject: [PATCH 046/126] feat(codex): let an operator keep a downgraded account out of pool rotation When a ChatGPT subscription lapses, the account is downgraded to Free and keeps taking production traffic until requests start failing. codexPool.excludedPlans lets an operator name the plan keys automatic selection skips. It is absent by default, so an existing install rotates exactly as before. The filter goes where pause already goes, in both isCodexAccountSelectable and the getEligiblePoolAccounts pool-row chain. That is not redundancy: the eligible list is only consulted when routing picks a NEW account, while an account that is already active or already bound to a thread is served straight out of isCodexAccountSelectable. A lapsed subscription leaves behind exactly that account, so filtering only the eligible list would have shipped a config key that reads correctly and changes nothing for the reporter. Two deliberate limits. The main account is exempt, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would answer differently under drain than under ordinary routing. And unlike pause, an excluded account still answers when no unexcluded candidate remains: #4211 asks for a selection policy rather than a hard block, and pausing every account is still how you stop serving entirely. No minimumPlan. Ranking ChatGPT plans against each other needs a total ordering this repository does not have. A malformed policy degrades to no policy rather than failing the parse, so a hand-edited typo cannot trip the backup-and-defaults repair path. Because that makes it a successful parse, the write path rejects it and loadConfig warns on all three success paths instead of letting it disappear in silence. Refs #4211 --- .../docs/fr/guides/codex-integration.md | 12 ++ .../content/docs/guides/codex-integration.md | 12 ++ .../docs/ja/guides/codex-integration.md | 12 ++ .../docs/ko/guides/codex-integration.md | 12 ++ .../docs/ru/guides/codex-integration.md | 12 ++ .../docs/tr/guides/codex-integration.md | 12 ++ .../docs/zh-cn/guides/codex-integration.md | 12 ++ .../docs/zh-tw/guides/codex-integration.md | 12 ++ scripts/test-layout/layout.json | 1 + src/codex/routing.ts | 49 ++++- src/config.ts | 62 ++++++ src/types/config.ts | 27 +++ .../codex-pool-plan-exclusion.test.ts | 178 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 14 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 tests/codex-integration/codex-pool-plan-exclusion.test.ts 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 1d9704e55f..e8a4deae4f 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -389,6 +389,18 @@ Lorsqu'un compte quitte la sélection du pool, la raison accompagne la décision Un renouvellement du compte principal qui n'aboutit pas répond toujours `503` avec `Retry-After`, car une nouvelle tentative peut réussir. Le message précise désormais qu'un échec persistant signifie que le compte principal doit être réauthentifié, au lieu de demander seulement de réessayer. +### Écarter de la rotation un compte rétrogradé + +`codexPool.excludedPlans` liste les clés de forfait que la sélection automatique du pool ignore, comparées sans tenir compte de la casse au forfait enregistré sur chaque compte. Absent par défaut : une installation existante effectue exactement la même rotation qu'avant. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +C'est une politique de sélection, pas un blocage. Un compte écarté conserve ses identifiants, son historique de quota et son affinité de thread, reste visible dans la liste des comptes et demeure joignable par sélection explicite comme `work/gpt-5.4`. Seule la rotation automatique cesse de le choisir, y compris lorsqu'il est déjà le compte actif ou déjà lié à un thread — l'état exact que laisse un abonnement expiré. + +Deux limites volontaires. Le compte Codex principal n'est jamais écarté par forfait, car le routage en mode sélection seule ne lit pas son forfait dans les identifiants natifs protégés ; une règle le couvrant se contredirait. Et lorsqu'il ne reste aucun compte non écarté, le compte écarté répond quand même au lieu d'échouer : mettre tous les comptes en pause reste le moyen d'arrêter complètement le service. Il n'existe pas de `minimumPlan`, car classer les forfaits ChatGPT entre eux exige un ordre total qui n'existe pas ici. + ## Restauration de Codex natif `ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index c1e296eb49..a99b880fc1 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -720,6 +720,18 @@ When an account leaves pool selection, the reason travels with the decision inst A main-account refresh that does not complete still answers `503` with `Retry-After`, because a retry may still succeed. The message now adds that a failure which persists means the main account needs reauthentication, rather than only asking for another attempt. +### Keeping a downgraded account out of rotation + +`codexPool.excludedPlans` lists plan keys that automatic pool selection skips, matched case-insensitively against the plan stored on each account. It is absent by default, so an existing install rotates exactly as before. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +This is a selection policy, not a block. An excluded account keeps its credential, quota history, and thread affinity, stays visible on the account surface, and is still reachable by explicit account selection such as `work/gpt-5.4`. What changes is that automatic rotation stops choosing it, including when it is already the active account or already bound to a thread — which is the state a lapsed subscription leaves behind. + +Two deliberate limits. The main Codex account is never excluded by plan, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would disagree with itself. And when no unexcluded account remains, the excluded one still answers rather than failing closed; pausing every account is still the way to stop serving entirely. There is no `minimumPlan` counterpart, because ranking ChatGPT plans against each other needs a total ordering that does not exist here. + ## Restoring native Codex `ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. 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 e64a87e77b..4ce54074d3 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -255,6 +255,18 @@ ocx service install # persistent: auto-starts on login and respawns on crash メインアカウントの更新が完了しない場合も、再試行で成功する可能性があるため `Retry-After` 付きの `503` を返します。ただしメッセージには、失敗が続くならメインアカウントの再認証が必要である旨を加えました。 +### ダウングレードしたアカウントをローテーションから外す + +`codexPool.excludedPlans` は、自動的なプール選択がスキップするプランキーの一覧です。各アカウントに保存されたプランと大文字小文字を区別せずに照合します。既定では未設定なので、既存の環境のローテーションは変わりません。 + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.4` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。 + +意図的な制限が2つあります。メインの Codex アカウントはプランによって除外されません。選択のみのルーティングは保護されたネイティブ資格情報を読まずにプランを伏せるため、メインを対象にすると挙動が食い違うからです。また、除外されていないアカウントが1つも残らない場合は、失敗させずに除外済みのアカウントが応答します。完全に停止したい場合は従来どおり全アカウントを一時停止してください。`minimumPlan` に相当する設定はありません。ChatGPT のプランを順位付けするには、ここに存在しない全順序が必要になるためです。 + ## ネイティブ Codexの復元 `ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ 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 12c26d382e..584e77adb0 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -266,6 +266,18 @@ ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 메인 계정 갱신이 끝나지 않은 경우에도 재시도로 성공할 수 있으므로 `Retry-After`와 함께 `503`을 반환합니다. 다만 실패가 계속되면 메인 계정을 다시 인증해야 한다는 내용을 메시지에 덧붙였습니다. +### 등급이 내려간 계정을 로테이션에서 빼기 + +`codexPool.excludedPlans`는 자동 풀 선택이 건너뛸 플랜 키 목록입니다. 각 계정에 저장된 플랜과 대소문자를 구분하지 않고 비교합니다. 기본값은 없음이므로 기존 설치의 로테이션은 그대로입니다. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.4` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다. + +의도한 제한이 두 가지 있습니다. 메인 Codex 계정은 플랜으로 제외하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않고 플랜을 감추기 때문에, 메인까지 적용하면 상황에 따라 판정이 어긋납니다. 그리고 제외되지 않은 계정이 하나도 남지 않으면 실패시키지 않고 제외된 계정이 그대로 응답합니다. 완전히 멈추려면 지금처럼 모든 계정을 일시 중지하면 됩니다. `minimumPlan`에 해당하는 설정은 없습니다. ChatGPT 플랜에 순위를 매기려면 여기 존재하지 않는 전순서가 필요합니다. + ## 네이티브 Codex 복원 `ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 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 44dcc35bff..e695830370 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -382,6 +382,18 @@ v1/base/v2 при делегировании и fallback — в Незавершённое обновление основного аккаунта по-прежнему отвечает `503` с `Retry-After`, потому что повтор может пройти. Теперь сообщение добавляет, что стойкий сбой означает необходимость повторной аутентификации основного аккаунта, а не только очередную попытку. +### Как убрать понижённый аккаунт из ротации + +`codexPool.excludedPlans` перечисляет ключи тарифов, которые автоматический выбор пула пропускает, сравнивая их с тарифом аккаунта без учёта регистра. По умолчанию ключ отсутствует, поэтому существующая установка работает ровно как раньше. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.4`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки. + +Два намеренных ограничения. Основной аккаунт Codex никогда не исключается по тарифу: маршрутизация в режиме «только выбор» скрывает его тариф, чтобы не читать защищённые нативные учётные данные, и правило для него противоречило бы само себе. А если не осталось ни одного неисключённого аккаунта, исключённый всё равно отвечает вместо отказа; чтобы остановить обслуживание полностью, по-прежнему нужно поставить на паузу все аккаунты. Аналога `minimumPlan` нет: чтобы ранжировать тарифы ChatGPT, нужен полный порядок, которого здесь не существует. + ## Восстановление нативного Codex `ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. 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 fcfa9f299d..23b9b87f2e 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -439,6 +439,18 @@ Bir hesap havuz seçiminden çıktığında neden, görüntüleme için yeniden Tamamlanmayan bir ana hesap yenilemesi, yeniden denemede başarılı olabileceği için hâlâ `Retry-After` ile `503` yanıtı verir. Mesaj artık kalıcı bir başarısızlığın ana hesabın yeniden kimlik doğrulaması gerektirdiğini de belirtiyor. +### Sürümü düşen bir hesabı rotasyondan çıkarma + +`codexPool.excludedPlans`, otomatik havuz seçiminin atladığı plan anahtarlarını listeler ve her hesapta saklanan planla büyük/küçük harf gözetmeden karşılaştırır. Varsayılan olarak yoktur; mevcut bir kurulum tam olarak eskisi gibi rotasyon yapar. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +Bu bir engelleme değil, seçim politikasıdır. Dışarıda bırakılan hesap kimlik bilgisini, kota geçmişini ve iş parçacığı bağını korur, hesap listesinde görünmeye devam eder ve `work/gpt-5.4` gibi açık bir seçimle hâlâ erişilebilir. Değişen tek şey, otomatik rotasyonun onu artık seçmemesidir; hesap zaten etkin olsa ya da bir iş parçacığına bağlı olsa bile. Süresi dolan bir abonelik tam olarak bu durumu bırakır. + +İki kasıtlı sınır var. Ana Codex hesabı plana göre hiçbir zaman dışarıda bırakılmaz: yalnızca-seçim yönlendirmesi korunan yerel kimlik bilgisini okumamak için planını saklar, dolayısıyla ana hesabı kapsayan bir kural kendisiyle çelişirdi. Ayrıca dışarıda bırakılmamış hiçbir hesap kalmadığında, dışarıda bırakılan hesap başarısız olmak yerine yine yanıt verir; hizmeti tamamen durdurmak için hâlâ tüm hesapları duraklatmak gerekir. `minimumPlan` karşılığı yoktur, çünkü ChatGPT planlarını sıralamak burada bulunmayan bir tam sıralama gerektirir. + ## Yerel Codex'i geri yükleme `ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. 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 92a3e94368..71d2b81473 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 @@ -326,6 +326,18 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 主账号刷新未完成时仍返回带 `Retry-After` 的 `503`,因为重试仍可能成功。消息中现在补充说明:若持续失败,则主账号需要重新认证,而不只是再试一次。 +### 让降级的账号退出轮换 + +`codexPool.excludedPlans` 列出自动账号池选择要跳过的套餐键,与每个账号上保存的套餐不区分大小写比对。默认不存在,因此现有安装的轮换完全不变。 + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.4` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。 + +有两处刻意的限制。主 Codex 账号不会因套餐被排除:仅选择模式的路由不读取受保护的原生凭据而隐去其套餐,覆盖主账号的规则会自相矛盾。另外,当没有未被排除的账号时,被排除的账号仍会应答而不是失败;要彻底停止服务,仍然是暂停全部账号。没有对应的 `minimumPlan`,因为给 ChatGPT 套餐排序需要一个这里并不存在的全序。 + ## 恢复原生 Codex `ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 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 4da96fe7f1..c9f8090987 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 @@ -333,6 +333,18 @@ ocx service install # 常駐:登入時自動啟動,崩潰後自動重新 主帳號更新未完成時仍回傳帶 `Retry-After` 的 `503`,因為重試仍可能成功。訊息現在補充說明:若持續失敗,代表主帳號需要重新認證,而不只是再試一次。 +### 讓降級的帳號退出輪換 + +`codexPool.excludedPlans` 列出自動帳號池選擇要略過的方案鍵,與每個帳號上儲存的方案不分大小寫比對。預設不存在,因此既有安裝的輪換完全不變。 + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.4` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。 + +有兩處刻意的限制。主 Codex 帳號不會因方案被排除:僅選擇模式的路由不讀取受保護的原生憑證而隱去其方案,涵蓋主帳號的規則會自相矛盾。此外,當沒有未被排除的帳號時,被排除的帳號仍會回應而不是失敗;要完全停止服務,仍然是暫停所有帳號。沒有對應的 `minimumPlan`,因為為 ChatGPT 方案排序需要一個這裡並不存在的全序。 + ## 恢復原生 Codex `ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2ab3271e2b..18bb3beefa 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -455,6 +455,7 @@ "codex-native-residue.test.ts": "codex-integration", "codex-plan.test.ts": "codex-integration", "codex-plugins-doctor.test.ts": "codex-integration", + "codex-pool-plan-exclusion.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration", "codex-prompt-adopt.test.ts": "codex-integration", "codex-prompt-base-variants.test.ts": "codex-integration", diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 5d8cc17d15..a1f1f4fbd9 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -19,7 +19,7 @@ import { selectPriorityTier, } from "./pool-rotation"; import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; -import { isThirtyDayOnlyCodexPlan } from "./plan"; +import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan, @@ -1004,6 +1004,50 @@ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): return getCodexAccountSoftAvoidUntil(accountId, now) !== null; } +/** + * Plan keys the operator excluded from automatic rotation. Absent or empty means no policy, so an + * existing install rotates exactly as before. Compared with `codexPlanKey` because the stored plan + * is an unrestricted provider string whose casing this repository does not control. + */ +function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | undefined { + const configured = config.codexPool?.excludedPlans; + if (!configured?.length) return undefined; + const keys = configured + .map(plan => codexPlanKey(plan)) + .filter((key): key is string => key !== undefined); + return keys.length > 0 ? new Set(keys) : undefined; +} + +/** + * Whether the operator's plan policy removes this account from automatic selection. + * + * Modelled on pause rather than usability: an excluded account keeps its credential, quota history, + * and affinity, stays visible on the account surface, and is still reachable by explicit account + * selection. Only automatic rotation skips it, which is the distinction #4211 asked for. + * + * It is checked in the same two places pause is checked, and that is not redundancy. The eligible + * list is consulted only when routing picks a NEW account; an already-active or already-affined + * account is served straight from {@link isCodexAccountSelectable}. A lapsed subscription leaves + * behind exactly that account, so a policy that filtered only the eligible list would miss the case + * it exists for. + * + * `__main__` is exempt. {@link getPoolAccountPlanForSelection} withholds the main plan during a + * selection-only drain so routing never reads the fenced native credential for it, so a rule that + * covered main would disagree with itself between drain and ordinary routing. + */ +function isCodexAccountPlanExcluded( + config: OcxConfig, + accountId: string, + precomputed?: ReadonlySet, +): boolean { + if (accountId === MAIN_CODEX_ACCOUNT_ID) return false; + // Callers that test a whole list pass the set once rather than rebuilding it per row. + const excluded = precomputed ?? excludedCodexPoolPlanKeys(config); + if (!excluded) return false; + const plan = codexPlanKey(getPoolAccountPlan(config, accountId)); + return plan !== undefined && excluded.has(plan); +} + function isCodexAccountSelectable( config: OcxConfig, accountId: string, @@ -1012,6 +1056,7 @@ function isCodexAccountSelectable( selectionOptions?: CodexAccountUsabilityOptions, ): boolean { return !isCodexAccountPaused(config, accountId) + && !isCodexAccountPlanExcluded(config, accountId) && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null && !isCodexAccountSoftAvoided(accountId, now) && isCodexAccountUsable(config, accountId, selectionOptions); @@ -1242,10 +1287,12 @@ function getEligiblePoolAccounts( selectionOptions?: CodexAccountUsabilityOptions, skipFailoverReadyCandidates = false, ): readonly string[] { + const excludedPlans = excludedCodexPoolPlanKeys(config); const ids = (config.codexAccounts ?? []) .filter(account => isSelectableCodexPoolAccount(account) && account.id !== excludeId && !isCodexAccountPaused(config, account.id) + && !isCodexAccountPlanExcluded(config, account.id, excludedPlans) && !isAccountNeedsReauth(account.id) && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now))) .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) diff --git a/src/config.ts b/src/config.ts index 4311e54eef..bda3b8a8ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1111,6 +1111,17 @@ const clientConnectionSchema = z.object({ }).optional(), }).strict(); +/** + * Codex pool selection policy section. + * + * `.strict()` like its neighbour: a typo in an optional feature section should surface as a + * rejected write rather than a silently ignored key that leaves the operator believing they + * excluded something. + */ +const codexPoolSchema = z.object({ + excludedPlans: z.array(z.string().trim().min(1)).optional(), +}).strict(); + /** * Quota-reset notification section. * @@ -1249,6 +1260,10 @@ const configSchema = z.object({ codexDesktopAuthless: z.boolean().optional().catch(undefined), codexClientCompaction: z.boolean().optional().catch(undefined), pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), + // A malformed policy degrades to "no policy" rather than failing the parse, so a hand-edited + // typo cannot trip the backup-and-defaults repair path and wipe providers or pool accounts. + // Silently ignoring it would be its own trap, so the write path rejects it and loadConfig warns. + codexPool: codexPoolSchema.optional().catch(undefined), codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined), codexAccountNamespaces: codexAccountNamespacesSchema.optional(), // Selection order is a preference, not a safety control like pause: a malformed @@ -2160,6 +2175,20 @@ function malformedQuotaResetNotifyWarning(rawParsed: unknown): string | null { return `quotaResetNotify${field ? `.${field}` : ""} ignored: invalid quota-reset notification configuration`; } +/** + * Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means + * the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is + * traffic going somewhere it was supposed to stop going. + */ +function malformedCodexPoolWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "codexPool")) return null; + const result = codexPoolSchema.safeParse(raw.codexPool); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`; +} + /** * Warn once per load that the section was dropped. * @@ -2172,6 +2201,18 @@ function warnDegradedQuotaResetNotify(rawParsed: unknown): void { if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); } +/** + * Warn once per load that the pool policy was dropped. + * + * `.catch(undefined)` turns a malformed policy into a SUCCESSFUL parse, so without this the proxy + * starts, rotates onto the accounts the operator meant to exclude, and prints nothing. The visible + * symptom would be traffic going exactly where it was told not to go. + */ +function warnDegradedCodexPool(rawParsed: unknown): void { + const warning = malformedCodexPoolWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; function rawConfigRecord(rawParsed: unknown): Record | null { @@ -2331,6 +2372,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); + warnDegradedCodexPool(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Schema validation failed — merge defaults into the raw object instead of @@ -2359,6 +2401,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); + warnDegradedCodexPool(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Still failing, but if every complaint is about one or more named entries @@ -2383,6 +2426,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); + warnDegradedCodexPool(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } } @@ -2527,6 +2571,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (clientWarning) warnings.push(clientWarning); const notifyWarning = malformedQuotaResetNotifyWarning(rawParsed); if (notifyWarning) warnings.push(notifyWarning); + const codexPoolWarning = malformedCodexPoolWarning(rawParsed); + if (codexPoolWarning) warnings.push(codexPoolWarning); if (syncDisabledReason) { warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); } @@ -2675,6 +2721,21 @@ function quotaResetNotifyError(value: unknown): string | null { return `schema_invalid: quotaResetNotify${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; } +/** + * The read path degrades a malformed pool policy to undefined, which for an exclusion policy means + * the excluded accounts quietly keep serving traffic. Reject it on write so `ocx config set` cannot + * create a policy that looks applied and is not. + */ +function codexPoolError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "codexPool") || raw.codexPool === undefined) return null; + const result = codexPoolSchema.safeParse(raw.codexPool); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: codexPool${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; +} + /** * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a * malformed selection-order map to undefined, which on a write would drop every entry the @@ -2850,6 +2911,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? upstreamHostCircuitThresholdError(value) ?? agentTaskRecoveryError(value) ?? quotaResetNotifyError(value) + ?? codexPoolError(value) ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) ?? codexQuotaAutoRefreshError(value) diff --git a/src/types/config.ts b/src/types/config.ts index f719e30161..3e5ac97a69 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -746,6 +746,14 @@ export interface OcxConfig { codexAccounts?: CodexAccount[]; /** Account ids administratively excluded from future pool selection until resumed. */ pausedCodexAccountIds?: string[]; + /** + * Codex pool selection policy. Absent means no policy, so an existing install rotates exactly + * as before. + * + * Not in `getDefaultConfig()` on purpose — that function carries no optional-feature keys, so + * absence is the only default state this policy has. + */ + codexPool?: OcxCodexPoolConfig; /** Opt-in per-account activation of newly reset Codex quota windows. */ codexQuotaAutoRefresh?: Record = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [ + { id: "downgraded", email: "downgraded@test", isMain: false, plan: "free" }, + { id: "paid", email: "paid@test", isMain: false, plan: "plus" }, + ], + activeCodexAccountId: "downgraded", + 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}`, + }); +} + +/** + * A `free` plan is thirty-day-only, so its usage score reads the monthly window while `plus` + * reads the weekly one. Recording both windows keeps these cases about the plan policy instead of + * about which account happened to have an observed window. + */ +function recordUsage(id: string, percent: number): void { + updateAccountQuota(id, percent, undefined, percent); +} + +describe("codex pool plan exclusion", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-plan-exclusion-")); + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearPoolRotationState(); + for (const id of ACCOUNT_IDS) clearAccountNeedsReauth(id); + for (const id of ACCOUNT_IDS) saveTestCredential(id); + }); + + afterEach(async () => { + const owned = testDir; + testDir = ""; + try { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + for (const id of ACCOUNT_IDS) clearAccountNeedsReauth(id); + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + 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 (owned) removeTreeWithRetry(owned); + } + }); + + test("no policy leaves rotation exactly as it was", () => { + const config = makeConfig(); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); + expect(resolveCodexAccountForThread("no-policy", config)).toBe("downgraded"); + }); + + test("an empty exclusion list is not a policy", () => { + const config = makeConfig({ codexPool: { excludedPlans: [] } }); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); + }); + + test("an excluded plan is skipped when routing picks a new account", () => { + const config = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + // Lower usage would otherwise win outright. + expect(pickLowestUsageCodexAccount(config)).toBe("paid"); + }); + + test("an account already serving a thread stops serving it once its plan is excluded", () => { + // The reported case: the account was paid, took traffic, and was then downgraded. It is both + // the active account and the affinity target, so the eligible list alone never sees it. + const config = makeConfig(); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(resolveCodexAccountForThread("lapsed-subscription", config)).toBe("downgraded"); + + config.codexPool = { excludedPlans: ["free"] }; + + expect(resolveCodexAccountForThread("lapsed-subscription", config)).toBe("paid"); + expect(previewCodexAccountForRequest("lapsed-subscription", config)).toBe("paid"); + }); + + test("plan matching ignores casing and surrounding whitespace on both sides", () => { + const config = makeConfig({ + codexAccounts: [ + { id: "downgraded", email: "downgraded@test", isMain: false, plan: " Free " }, + { id: "paid", email: "paid@test", isMain: false, plan: "plus" }, + ], + codexPool: { excludedPlans: ["FREE"] }, + } as Partial); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("paid"); + }); + + test("an account with no recorded plan is never excluded by a plan policy", () => { + const config = makeConfig({ + codexAccounts: [ + { id: "downgraded", email: "downgraded@test", isMain: false }, + { id: "paid", email: "paid@test", isMain: false, plan: "plus" }, + ], + codexPool: { excludedPlans: ["free"] }, + } as Partial); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); + }); + + test("a plan nobody holds excludes nobody", () => { + const config = makeConfig({ codexPool: { excludedPlans: ["go"] } }); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); + }); + + test("the last remaining account still serves rather than stranding the operator", () => { + // Deliberately unlike pause. #4211 asks for a selection policy, not a hard block, so with no + // unexcluded candidate left the excluded account keeps answering instead of failing closed. + const config = makeConfig({ + codexAccounts: [{ id: "downgraded", email: "downgraded@test", isMain: false, plan: "free" }], + codexPool: { excludedPlans: ["free"] }, + } as Partial); + recordUsage("downgraded", 10); + expect(pickLowestUsageCodexAccount(config)).toBeNull(); + expect(resolveCodexAccountForThread("last-account", config)).toBe("downgraded"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2c2c04c2e4..cf967a487b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -290,6 +290,7 @@ "codex-native-residue.test.ts": "codex-integration", "codex-plan.test.ts": "codex-integration", "codex-plugins-doctor.test.ts": "codex-integration", + "codex-pool-plan-exclusion.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration", "codex-prompt-adopt.test.ts": "codex-integration", "codex-prompt-base-variants.test.ts": "codex-integration", From 46e2a3774c4242e7a190523669b95b334305982f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:22:56 +0900 Subject: [PATCH 047/126] docs(devlog): record the L3 WP3 plan-policy unit and its packet departure --- .../030_wp3_4211_plan_exclusion.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md diff --git a/devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md b/devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md new file mode 100644 index 0000000000..f40e5d4d0f --- /dev/null +++ b/devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md @@ -0,0 +1,79 @@ +# WP3 — #4211 keep Free-tier ChatGPT accounts out of Codex pool selection + +## Where this departs from the packet, and why + +The packet's recorded decision was to filter in `getEligiblePoolAccounts` at `routing.ts:1248` +"rather than in `isCodexAccountUsable`". That choice is right and was kept. But the feasibility +audit that produced it did not consider a third site, and shipping only the recorded one would have +left the feature inert for the exact case the issue reports. + +A read-only subagent traced it end to end. `getEligiblePoolAccounts` is the choke point for +picking a **new** account. An account that is already the active account, or already bound to a +thread by affinity, is served straight out of `isCodexAccountSelectable`, which builds its own +predicate and never consults the eligible list. Both paths return the account without the eligible +list being built at all — affinity reuse at `routing.ts:2126`, keep-active at `:2198` — and +priority preemption cannot rescue it because every account defaults to priority 0, so +`priorityOf(eligible[0]) <= priorityOf(active)` and it returns null. + +The reporter's account is precisely that account: it was paid, it was taking traffic, and then the +subscription lapsed. Filtering only the eligible list would have shipped a config key that reads +correctly and changes nothing for them. + +So the filter goes where pause already goes. Pause is checked in **both** places — +`isCodexAccountSelectable` at `:1014` and the `getEligiblePoolAccounts` pool-row chain at `:1248` +— and pause is the mechanism the issue itself names as today's manual workaround. Copying its two +insertion points is the smallest change that makes the policy true. + +## Decisions this lane had to make + +**The main account is exempt.** `getPoolAccountPlanForSelection` withholds the main plan during a +selection-only drain so routing never reads the fenced native credential for it. A rule covering +main would therefore exclude it under ordinary routing and not under drain — the same account, +two answers. Exempting it keeps the two consistent and avoids introducing a drain-time credential +read. Confirmed by audit: `isCodexAccountPlanExcluded` returns at the `__main__` check before any +plan lookup, so no new native read exists on any routing path. + +**The last remaining account still serves.** Pause fails closed: all-paused returns `{status: +"none"}`. Plan exclusion deliberately does not copy that leftover. #4211 asks for "a selection +policy, not a hard block" and for an explicit route to keep working, and stranding an operator +whose remaining accounts are all excluded is a worse outcome than serving one downgraded request. +Pausing every account remains the way to stop serving entirely. Pinned by a test. + +**No `minimumPlan`.** Per the packet: ranking plans needs an ordering this repository does not +have. + +**Malformed policy degrades rather than failing the parse**, matching `quotaResetNotify`, because a +hand-edited typo must not trip the backup-and-defaults repair path and wipe providers or pool +accounts. The write path rejects it and `loadConfig` warns on all three success paths, so it cannot +degrade silently. + +## Out of scope + +`docs-site/src/content/docs/reference/configuration/providers.md` carries the field table where +`pausedCodexAccountIds` and `codexAccountPriorities` are listed, and `codexPool.excludedPlans` +belongs beside them. That file is not in the L3 owned list, so this lane documented the key in the +Codex integration guide it does own and reports the reference-table row as a follow-up. + +## Audit + +Four read-only `xai/grok-4.6` subagents. Two returned **fail** and both were folded in rather than +argued with. + +- **Routing (fail → pass).** Four of the eight tests would have failed. Three because a `free` plan + is thirty-day-only and scores on the monthly window, while the fixture recorded weekly only, so + the account scored `CODEX_UNKNOWN_USAGE_SCORE` and lost the ranking even with no policy — the + "no policy changes nothing" tests would have passed for the wrong reason and then failed. One + because `previewCodexAccountForRequest` takes `(threadId, config)` and was called with one + argument. Both fixed; the per-row `Set` rebuild it also flagged was hoisted. +- **Config (fail → fixed).** The schema comment claimed `loadConfig` warns, and it did not: + the warning had only been wired into the diagnostics array that `ocx config show --source` + prints, not into the `warnDegraded*` helpers the proxy calls at start. Since `.catch(undefined)` + makes a malformed policy a *successful* parse, that gap meant the proxy would start, rotate onto + the accounts the operator meant to exclude, and print nothing. +- **Re-audit (pass).** All eight tests predicted to pass; both gates match pause; `__main__` exempt + before any plan read; an absent `codexPool` is a total no-op. + +## Verification + +Local suite, typecheck, and build: NOT RUN by operator instruction. Hosted CI on the pushed head is +the evidence. From 091b7f3e0da5710a804d8a84a5fb5daa48e1f6fc Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:22:59 +0900 Subject: [PATCH 048/126] docs(devlog): lock the L7 wordings for #4215 and #4200 Three adversarial read-only audits rejected revisions 1 and 2 of the wording unit. The refuted claims are recorded next to the corrected ones so the next reader does not reintroduce them: OpenCodex does not never convert an OAuth login into a key (orcarouter-oauth mints one by design), an API key does not always bill per token (several presets are coding-plan subscriptions bought as a key), and the registry cannot prove vendor billing terms at all - it proves which account a credential authenticates as. --- devlog/_plan/260911_l7_docs/010_roadmap.md | 64 +++++++ .../_plan/260911_l7_docs/020_4215_wording.md | 140 ++++++++++++++++ .../_plan/260911_l7_docs/030_4200_recipe.md | 157 ++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 devlog/_plan/260911_l7_docs/010_roadmap.md create mode 100644 devlog/_plan/260911_l7_docs/020_4215_wording.md create mode 100644 devlog/_plan/260911_l7_docs/030_4200_recipe.md diff --git a/devlog/_plan/260911_l7_docs/010_roadmap.md b/devlog/_plan/260911_l7_docs/010_roadmap.md new file mode 100644 index 0000000000..602be58a70 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/010_roadmap.md @@ -0,0 +1,64 @@ +# L7 roadmap — two documentation units, locked before either guide is edited + +Base: `origin/dev` at rebase time, branch `codex/260911-l7-docs`. Packet: `000_packet.md`. + +This is the docs-first cycle the loop requires. Nothing in `docs-site/` is edited until the two +wordings below are locked, because both issues propose wording that the source does not support and +writing first would have shipped two wrong claims. + +## Unit order + +1. **#4215** — `docs-site/src/content/docs/guides/providers.md`. The rule per authentication mode, + then one explicit line per provider that accepts both a subscription login and an API key. + Wording locked in `020_4215_wording.md`. +2. **#4200** — `docs-site/src/content/docs/guides/remote-hub.md`. Fresh-config object + initialization, field preservation, data-plane versus management-plane separation, and a macOS + Tailscale Serve data-plane TLS path. Recipe locked in `030_4200_recipe.md`. + +PR 1 targets `dev`. PR 2 targets PR 1's head branch and is retargeted to `dev` after PR 1 lands. + +## What this lane may touch + +`docs-site/src/content/docs/guides/providers.md`, `docs-site/src/content/docs/guides/remote-hub.md`, +one new regression test per unit plus its two registrations in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`, and this devlog unit. Nothing else — no `src/`, no +`gui/src/i18n/`, and none of the seven translated copies of either guide. Translations are a +follow-up, which is what #4200's own review comment asks for. + +## Verification posture + +The local product suite is NOT RUN by operator instruction: no `bun test`, no `bun run test`, no +`bun run test:changed`, no `bun run typecheck`, no `bun run build:gui`, no `bun install`. Hosted CI +on the exact pushed head is the only product evidence this round accepts. Every mutating git command +is prefixed with `git -c core.hooksPath=/dev/null` and every push uses `--no-verify`, because this +repository's hooks can start a GUI install, a typecheck, and a build — the same forbidden work +through a side door. + +Confidence that would normally come from a local run comes instead from read-only `xai/grok-4.6` +subagents: every claim written into either guide carries a `path:line` anchor recorded in the unit +doc, and the staged diff is reviewed adversarially before each push. + +## Regression tests + +Both guards live in `tests/ci-workflows/`, beside `docs-429-failover-claims.test.ts`, which is the +existing precedent for pinning a published claim that drifted away from the runtime. A subagent +confirmed that directory is the docs-guard home and that no test anywhere asserts the +`config parent path not found` behaviour today. + +Each new file is registered in `scripts/test-layout/layout.json` `explicit` and in +`tests/fixtures/test-layout-expected.json`. Both maps are append-only and other lanes are appending +too; the orchestrator resolves the merge conflicts. + +## Open decisions this lane had to make + +- **#4215 form.** The issue left "table or per-section sentence" open. The packet decided: the rule + per authentication mode first, then one explicit line per dual-mode provider. A table carries the + per-provider lines because the reader's question is a lookup. +- **#4215 scope.** The issue left "dual-mode providers only, or the full roster" open. The packet + decided dual-mode only. Providers that offer exactly one mode are already unambiguous. +- **#4215 verification surface.** The issue asks the guide to point at "the account card in the + dashboard". No such per-account badge exists. The guide points at the surface that does exist — + see `020_4215_wording.md`. +- **#4200 product change.** The issue's review comment explicitly leaves auto-creating a missing + parent object out of scope. This lane documents the CLI as it behaves and does not touch + `src/cli/config-command.ts`. diff --git a/devlog/_plan/260911_l7_docs/020_4215_wording.md b/devlog/_plan/260911_l7_docs/020_4215_wording.md new file mode 100644 index 0000000000..5acbc1618a --- /dev/null +++ b/devlog/_plan/260911_l7_docs/020_4215_wording.md @@ -0,0 +1,140 @@ +# #4215 — locked wording: which account a provider login spends + +Target: `docs-site/src/content/docs/guides/providers.md`. Closes #4215. + +Revision 3. Revisions 1 and 2 were each rejected by an adversarial read-only audit that opened every +anchor. Revision 1 had wrong line numbers; revision 2 had a deeper problem, and fixing it changed +what this document promises. + +## The framing correction that revision 3 is built on + +Revision 2 wrote sentences like "spends the Claude subscription allowance" and cited a registry or +quota line. The audit's objection is correct and it matters: **those lines do not state vendor +billing terms.** `registry.ts` states an id, a transport, a base URL and an auth kind. +`quota.ts` states which usage windows OpenCodex reads. Neither knows what Anthropic or xAI charges. +Citing them as proof of a billing claim is exactly the kind of overreach a maintainer should reject, +and `quota.ts:1404`-`1409` says outright that the Anthropic endpoint reports no tier at all. + +So the guide answers the question the reader is actually asking — *which account does this spend?* — +in terms the source can support: + +- which credential the request carries, +- which account that credential authenticates as, +- which usage windows OpenCodex reads back, if any. + +Whether that account is billed per token or against a plan is the vendor's term, not OpenCodex's. The +guide says so once, plainly, and does not pretend otherwise per provider. + +## Why the issue's own suggested wording could not be shipped verbatim + +The issue proposes "an OAuth or subscription login consumes that subscription's allowance, an API key +bills that key's account per token, and OpenCodex does not convert one into the other." + +**"Never converts" is refuted.** `orcarouter-oauth` is a browser-consent PKCE flow whose purpose is +to mint a durable user-owned `sk-orca-…` key and store it in the OAuth credential store +(`src/oauth/orcarouter.ts:12` for the prefix, `:95`-`99` for the long-lived-key comment; +`src/providers/registry.ts:1441`). The login *is* the key delivery mechanism. + +**"Bills per token" is not universally true of a key.** Several key presets are subscriptions bought +as a key: `zai` GLM Coding Plan (`registry.ts:2544`), `zhipu-bigmodel-coding` whose comment says +"this endpoint is the subscription product" (`registry.ts:2619`), `kimi-code` "API-key form of the +same Kimi Code Plan transport" (`registry.ts:2971`). CodeBuddy is blunter: "Free/trial/promotional/ +subscription credits draw from the same official API-key pool" (`registry.ts:3234`). + +**What is true, and is what gets written.** One request carries exactly one credential family. +`DispatchBinding` is either `{kind:"oauth"}` or `{kind:"api-key"}` (`src/server/responses/core.ts:3974`-`3976`). +When an OAuth credential cannot be resolved the request fails back to the caller — the catch returns +`401 authentication_error` and the same block returns 429, 409 and 400 on other paths +(`core.ts:4268`-`4393`) — and at no point does it reach for a stored key. Key-pool 429/401 failover +refuses oauth and forward outright (`src/providers/key-failover.ts:99`). *Audit correction: revision 2 +said 401 was the only outcome of that block.* + +**The key-deletion claim needs a carve-out.** OAuth login deletes `apiKey` and `apiKeyPool` under +the comment "OAuth-only providers must never retain credentials for a different auth mechanism" +(`src/oauth/index.ts:1494`-`1497`). The next block restores them when the preset sets +`allowKeyAuthOverride`, and if the previous `authMode` was `key` or unset it sets `authMode` back to +`"key"` (`oauth/index.ts:1498`-`1519`). On `xai` and `github-copilot`, running `ocx login` over an +existing key configuration can leave the provider still billing the key. The guide says so. + +## Locked rule, per authentication mode + +- `forward` — the ChatGPT/Codex login. The request carries your Codex credential, so it spends the + ChatGPT plan behind that login and reports that plan's Codex quota windows. **Which windows exist + is plan-dependent** — the five-hour window is not universal and some plans report weekly only + (`src/codex/quota.ts:441`-`442`). It never reads an API key: an `openai` provider row with no + `authMode` is backfilled to `forward` in `routedProviderConfig` (`src/router.ts:313`-`319`, `:429`), + and the forward branch of the adapter uses the Codex account override, with `provider.apiKey` only + on the else branch (`src/adapters/openai-responses.ts:2319`-`2367`, `:2363`). *Audit corrections: + revision 2 promised all three windows unconditionally and cited `router.ts:654`, which is a + namespace-check mirror rather than the backfill.* +- `oauth` — a subscription login. The request carries a stored access token, so it spends whatever + account you logged in as, and OpenCodex reports whichever usage windows that provider exposes. +- `key` — the request carries the key you supplied, so usage lands on the account that owns it, on + that key's own terms: metered usage for a pay-as-you-go API account, or a plan allowance when the + key *is* a coding-plan subscription. The guide says "per token" only where the source does, as in + `meta-model` — "every call is metered per token" (`registry.ts:1639`). +- A request never swaps families. + +Two stated exceptions, because both are shipped behaviour a reader can hit: + +- `xai` and `github-copilot` accept `authMode: "key"` on the same provider id + (`allowKeyAuthOverride`, `registry.ts:1274` and `registry.ts:3157`). Which account pays is a + persisted configuration choice, and per the carve-out above, logging in does not necessarily take + you out of key mode. +- `orcarouter-oauth` mints a user-owned API key from the consent flow + (`src/oauth/orcarouter.ts:12`, `:95`-`99`, `registry.ts:1441`). From then on the request carries a + key, so it falls under the `key` rule above: usage lands on the OrcaRouter account that owns it. + +## Locked per-provider lines + +Each cell states which account the credential authenticates as, and nothing about vendor pricing that +the anchor does not carry. + +| Provider | Subscription login | API key | +|---|---|---| +| OpenAI / ChatGPT | `openai` — Codex login, `authKind: "forward"`; spends the ChatGPT plan behind that login (`registry.ts:1222`) | `openai-apikey` — a separate preset; usage lands on the OpenAI Platform account that owns the key (`registry.ts:1573`) | +| Anthropic | `ocx login anthropic` — signs in as your Claude account (`registry.ts:1444`); OpenCodex reads its five-hour and seven-day usage windows, and that endpoint reports no tier (`quota.ts:1404`-`1409`) | `anthropic-apikey`, labelled "Direct Anthropic API billing — no Claude subscription" (`registry.ts:1472`) | +| xAI | `ocx login xai` — the Grok CLI subscription gateway; OpenCodex reads SuperGrok weekly credits, else the legacy monthly pool (`quota.ts:1285`-`1370`) | same `xai` id with `authMode: "key"` (`registry.ts:1274`); key mode targets `https://api.x.ai/v1` (`registry.ts:1272`), so usage lands on that API account | +| Kimi | `ocx login kimi` — "Log in with your Kimi account" (`registry.ts:1481`, note at `:1496`) | `kimi-code`, "API-key form of the same Kimi Code Plan transport" (`registry.ts:2971`) | +| Command Code | `ocx login command-code` — OpenCodex reads five-hour and weekly windows plus a credit balance (`quota.ts:2392`-`2447`) | `commandcode` on `/provider/v1` (`registry.ts:2262`) | +| GitHub Copilot | `ocx login github-copilot` — "Requires an active Copilot subscription" (`registry.ts:3183`) | same `github-copilot` id with `authMode: "key"` (`registry.ts:3157`) | +| OrcaRouter | `ocx login orcarouter-oauth` — consent mints a user-owned, long-lived `sk-orca-…` key (`src/oauth/orcarouter.ts:12`, `:95`); the request then carries a key, so usage lands on that key's account | `orcarouter` — the same key pasted by hand (`registry.ts:1924`) | +| Meta Muse | `ocx login meta-muse` (`registry.ts:1650`) imports the Muse Code CLI key. Meta scopes that credential to its own CLI, so this is an **unsupported** use: how the calls settle is not observable from the API and every call should be treated as billable against your account (`registry.ts:1666`) | `meta-model` is the supported path — "every call is metered per token", and a Muse Code subscription does **not** work there (`registry.ts:1639`) | +| Google Antigravity | `ocx login google-antigravity` — signs in with your Google account over the Cloud Code Assist wire (`registry.ts:1997`) | no key mode on this id. `google` is a separate AI Studio Gemini preset reached with its own key (`registry.ts:1978`), so it is a different product rather than a key mode for this login | + +*Audit corrections folded here: Meta Muse retargeted from `:1673` (which is `umans`) to `:1650` and +`:1666`; Kimi, Antigravity and xAI reworded so the sentence matches what its anchor says. A third +audit pass then removed the last two vendor-billing words the anchors did not carry — OrcaRouter's +"balance" and Google's "billing".* + +Login-only, no API-key equivalent: `cursor` (`registry.ts:1233`), `kiro` (`registry.ts:1513`), +`nous` (`registry.ts:1546`). Two audits searched the registry for a login+key twin this table might +have missed and found none. + +## Where the reader verifies their own setup + +The issue asks the guide to say that "the account card in the dashboard shows which mode an account +is using". It does not. The per-account rows render alias, email, id, health, reauth and active +state, and carry no auth-mode badge (`ProviderAuthPanel.tsx:504`-`527`). + +The mode is a **provider-level** field (`provider-routes.ts:737`) rendered as the Connection block's +**Authentication** row (`ProviderOverview.tsx:167`, label at `:168`). Its `authModeLabel` is `OAuth`, +`API key`, `ChatGPT passthrough` or `Local`, with a `No key needed` fallback for a preset that needs +no credential (`gui/src/i18n/en.ts:1136`-`1194`, `ProviderRail.tsx:34`). *Audit correction: revision 2 +listed only four labels.* + +The guide does **not** claim the separate **AUTHENTICATION** block prints those labels — for forward +it prints "Codex passthrough" (`en.ts:1425`), and for oauth and key it prints login state and +"API key configured". + +**Decision: the guide points at the surface that exists** and names the labels that row actually +renders, rather than repeating the issue's phrasing and sending readers hunting for a badge that was +never built. No locale key is added; every label quoted already ships. + +## Regression guard + +`tests/ci-workflows/docs-provider-billing-claims.test.ts`, beside `docs-429-failover-claims.test.ts`. +It pins the claims that would silently rot: the per-mode rule is present, every dual-mode provider +above has a row naming both of its modes, and the guide does not reintroduce the refuted absolute +"never converts" phrasing. Registered in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. NOT RUN locally; hosted CI is the proof. diff --git a/devlog/_plan/260911_l7_docs/030_4200_recipe.md b/devlog/_plan/260911_l7_docs/030_4200_recipe.md new file mode 100644 index 0000000000..4f388c09d7 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/030_4200_recipe.md @@ -0,0 +1,157 @@ +# #4200 — locked recipe: fresh-config initialization and the macOS data plane + +Target: `docs-site/src/content/docs/guides/remote-hub.md`. Closes #4200. + +Revision 2, after the same adversarial audit that rejected revision 1 of `020`. Three anchors here +were wrong; they are corrected and marked below. Every behavioural claim survived. + +## Defect 1 — the nested set the guide tells you to run + +`setPath` walks `segments.slice(0, -1)` and throws when a parent is absent +(`src/cli/config-command.ts:59`-`61`): + +``` +config parent path not found: hub +``` + +`ocx config set runtimeRole hub` does **not** create the object — it assigns one leaf +(`config-command.ts:67`), and neither `runtimeRole` nor `hub` has a default +(`src/config.ts:1144`-`1147`, `getDefaultConfig` at `config.ts:3872`). So the guide's very next +three lines cannot run on the fresh standalone install it just told the reader to make. + +**No test anywhere asserts this behaviour.** The only other place the workaround is documented is +`docs-site/src/content/docs/reference/configuration/server.md:248`. + +### Locked fix + +Two supported forms, and the guide shows both because they are good at different things. + +1. **Initialize the empty parent, then set fields.** `hub` has no parent segments, so + `ocx config set hub '{}'` assigns the leaf directly and succeeds; `{}` is a valid `hub` object + (`tests/server/loopback-listener-admission.test.ts:183`-`185`). Every later `hub.` set + then finds an object parent. This is the form for adapting a config that may already have the + object, because each nested set replaces one key only. +2. **Set the whole object in one call**, which is what the issue proposes, for a fresh config. + +### The warning the issue asked for, stated precisely + +A whole-object set **replaces**, it does not merge: `setPath` ends in `current[leaf] = value` +(`config-command.ts:67`) with no `Object.assign`. Writing `hub '{"managementPublicOrigin":"…"}'` on +a config that already had `hub.managementIngress` silently drops the ingress. + +### Two facts worth stating that the issue did not raise + +- The value argument is parsed as JSON first and falls back to the raw string + (`config-command.ts:70`-`73`). That is why the guide writes `'"https://…"'`. Objects, arrays, + booleans and numbers **must** be valid JSON; a bare URL only works by falling through the catch. +- `hub` and `remoteGui` are `.strict()` (`src/config.ts:1029`-`1045`, `1056`-`1070`), so a mistyped + key is rejected at write time as `schema_invalid: hub.` (`config.ts:2640`), and + `managementPublicOrigin` must be a canonical origin with no path, query or fragment + (`config.ts:1031`-`1037`). A reader who mistypes gets a real error rather than a dead setting. + +## Defect 2 — the macOS data plane has no TLS path + +The guide binds the data listener to the tailnet IP, publishes only the loopback **management** +ingress through Serve, then shows `ocx connect` against an HTTPS origin. The management ingress is +default-deny for `/v1/*`, `/healthz` and `/readyz`, which 404 before any handler +(`src/server/index.ts:851`-`872`, `1083`-`1088`). The guide never closes the data plane. + +**opencodex terminates no TLS of its own.** `Bun.serve` is called with `port`, `hostname`, +`idleTimeout`, `maxRequestBodySize` and `fetch` (`src/server/index.ts:1063`-`1069`, `2405`); there +is no `tls`/`cert`/`key` field anywhere in `OcxConfig`. TLS is always the operator's frontend, and +the guide says so outright. + +### The macOS constraint, from Tailscale's own documentation + +Serve's HTTP reverse-proxy backend is limited to `127.0.0.1`, so it cannot proxy to another address +— including the node's own tailnet IP +([serve CLI reference](https://tailscale.com/docs/reference/tailscale-cli/serve)). The App Store +build adds a sandbox restriction on top: it proxies local ports but not an arbitrary remote +destination ([macOS variants](https://tailscale.com/docs/concepts/macos-variants)). That is exactly +the refusal the issue reports, and it is a platform limitation, not an opencodex defect. + +### The admission predicate that decides the whole recipe + +`isApiAuthRequired` is `!isLoopbackHostname(config.hostname)` — keyed on the **configured bind +address**, not on the socket the request arrived on and not on the `Host` header +(`src/server/auth-cors.ts:288`-`290`; the public listener passes `config` straight through at +`src/server/index.ts:1095`). *Audit correction 8: revision 1 said "via `requestPolicyView`", which +is the separate unauthenticated loopback listener, not this path.* + +`isAllowedRequestOrigin` then branches: the loopback arm is `auth-cors.ts:90`-`94` and the +non-loopback arm is `auth-cors.ts:96`. *Audit correction 10: revision 2 cited `90`-`94` for both +arms, and that range is only the loopback one.* + +- **Loopback bind.** No data credential is required, and the request's `Host` must itself be + loopback. A TLS terminator forwards `Host: hub-name.tailnet-name.ts.net`, so `/v1/catalog` + returns `403 origin_rejected` (`src/server/index.ts:1303`). +- **Non-loopback bind.** A data credential is required and the `Host` check does not apply; a CLI + client sends no `Origin`, so it is admitted. + +Nothing in the request path reads `X-Forwarded-Host` or `Forwarded` — tests send them precisely to +prove they are ignored — so the terminator cannot repair this. + +| `hostname` | Serve can reach it? | `/v1/catalog` | Verdict | +|---|---|---|---| +| `127.0.0.1` | yes, directly | **403 `origin_rejected`** — `Host` is the ts.net name | the trap; `/readyz` still passes and hides it | +| `0.0.0.0` | yes, via loopback | works, credential required | works, but publishes the data port on every interface | +| tailnet IP | **no** — Serve's backend must be `127.0.0.1` | works, credential required | correct bind; needs a loopback forwarder in front | + +`/readyz` does not run the `Host` check (`src/server/index.ts:1222`-`1242`), which is why the trap is +silent: readiness passes and the catalog fails. + +### Locked recipe + +Keep `hostname` on the tailnet IP, put a loopback TCP forwarder in front of it, and point Serve at +the forwarder — which is what the reporter actually deployed successfully. Two Serve mappings: +management on `--https=443` to `127.0.0.1:10101`, data on `--https=8443` to the forwarder. The guide +tells the reader to confirm both with `tailscale serve status` rather than asserting which HTTPS +ports Serve permits. Then: + +``` +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +The positional URL is the **data-plane** origin: `GET /readyz` (`src/client/connect.ts:506`-`519`) +and then the catalog download against that same `serverUrl` (`connect.ts:542`, +`src/client/hub-client.ts:432`-`445`). *Audit correction 6: revision 1 cited `hub-client.ts:174`-`201`, +which is URL normalization, not the catalog fetch.* + +`--management-url` is a separate management origin, and when omitted it is taken from the `/readyz` +metadata — which is `hub.managementPublicOrigin` (`src/remote/protocol.ts:46`-`55`). The two are +resolved independently at `connect.ts:506`-`519` and nothing requires them to match: the catalog is +fetched from `serverUrl` (`connect.ts:542`) while key issuance goes to `managementUrl` +(`connect.ts:532`). *Audit correction 7: revision 1 cited `normalizeHubOrigin`, which validates a +single URL and proves nothing about the pair. Audit correction 11: revision 2 pointed key issuance +at the resolution and catalog lines instead of `issueClientKey` at `:532`.* + +The guide keeps its existing acceptance rule — `/readyz`, an authenticated `GET /v1/catalog`, and +one real routed response. This recipe is what finally makes all three reachable on macOS. + +### Correction the guide needs anyway + +The troubleshooting list still offers `--allow-insecure-http` +(`docs-site/src/content/docs/guides/remote-hub.md:332`). That flag does not exist: it is absent from +`CONNECT_USAGE` (`src/cli/connect.ts:31`-`39`), pairing refuses non-loopback HTTP outright +(`src/client/hub-client.ts:247`-`254`), and `remoteGui.allowInsecureHttp` is a retired no-op +(`src/config.ts:1067`-`1069`). + +**Decision: fix it in the same PR.** It is one line in an owned file, it is the same class of defect +the issue reports — a published command that cannot run — and leaving a known-false command next to +the one being corrected would be indefensible. The PR says so explicitly. + +The dead flag also appears in all seven translations (`ko:137`, `ja:107`, `zh-cn:104`, `zh-tw:85`, +`fr:106`, `ru:109`, `tr:109`). Those files are outside this lane's owned paths and are recorded as +a follow-up, consistent with the issue's own "English source first, translations later". + +## Regression guard + +`tests/ci-workflows/docs-remote-hub-claims.test.ts`, beside `docs-429-failover-claims.test.ts`. +It pins: the setup section never issues a nested `ocx config set hub.` or +`remoteGui.` before the parent object exists, the replace-not-merge warning is present, the +guide states opencodex terminates no TLS itself, `ocx connect` appears with a data URL and a +separate `--management-url`, and `--allow-insecure-http` does not reappear. Registered in +`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. NOT RUN locally; +hosted CI is the proof. From 8dcd1c10e9d3afbba046f847dbd17f74da8a4fc7 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:23:29 +0900 Subject: [PATCH 049/126] test(codex): pin the boot-probe guard by what it wraps, not by adjacency The carry gates the #1849 boot probe on the npm layout, because that probe is npm's transactional stage/swap/backup and pnpm rolls back through its own global path instead. The condition went from !codexCliUpdateInspection && isNodeModulesInstall() && !isBunGlobalInstall() to !codexCliUpdateInspection && installMethod === "npm" && isNodeModulesInstall() && ... The launcher-policy oracle asserted the first two clauses as an adjacent string, so inserting a condition between them failed it on Linux and macOS even though the invariant it protects - the codex-cli-update namespace never runs boot repair - is untouched and still evaluated first. The oracle now locates the guard that actually wraps the bootRestoreProbe call and asserts both clauses are in it. Removing either clause still fails it. --- .../codex-cli-update-launcher-policy.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts index b9bc83299b..6336474557 100644 --- a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts +++ b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts @@ -19,7 +19,15 @@ describe("Codex CLI updater launcher policy", () => { test("launcher skips boot repair and lazy Bun installation for this namespace", () => { const source = readFileSync(repoPath("bin", "ocx.mjs"), "utf8"); - expect(source).toContain("!codexCliUpdateInspection && isNodeModulesInstall()"); + // Read the guard that actually wraps the probe rather than a fixed pair of adjacent + // clauses. The boot probe is npm's transactional layout (stage/swap/backup) and pnpm + // rolls back through its own global path, so the condition list grows; what must not + // change is that this namespace is excluded from it. + const probeCall = source.indexOf("const probe = bootRestoreProbe("); + expect(probeCall).toBeGreaterThan(0); + const guard = source.slice(source.lastIndexOf("if (", probeCall), probeCall); + expect(guard).toContain("!codexCliUpdateInspection"); + expect(guard).toContain("isNodeModulesInstall()"); expect(source).toContain("resolveBun({ allowInstall: !codexCliUpdateInspection })"); expect(source).toContain("if (allowInstall && existsSync(installJs))"); }); From 1ada8f5ff1b902aa15deb72d38349488c8bcbdde Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:20:19 +0900 Subject: [PATCH 050/126] fix(stop): name the real refusal cause instead of asserting ownership POST /api/stop refuses for three distinct reasons - respawnable_service, self_unload_service and service_state_unknown - but when no readable message arrived, stopProxy fell back to a single hardcoded sentence naming a fourth cause the server never reports: a CODEX_HOME/OPENCODEX_HOME ownership mismatch. The reporter's homes matched exactly and the server had answered respawnable_service, so three attempts went into re-exporting CODEX_HOME. Carried from #4170 by @yeongjunyoo: the refusal code is captured next to the message, the fallback wording is selected from that code, and the refusal is returned per attempt rather than published to module state, so two overlapping stops cannot lend each other the wrong cause. This also closes the second half of the issue, which the carry left open. #4169 asks that "the recommended next command should not be the command the operator just ran", and it is still is: ocx stop reaches POST /api/stop through handleStop -> stopWithDeferral -> stopProxy, and the server answers "the stop must be run by `ocx stop`" because that refusal is written for an API client. The CLI echoes it verbatim, so following the advice returns the operator to the same message. The carried fallback said "Run `ocx stop`" too, making the empty-body path a tighter loop than before. The refusal code now travels on the error, the fallback messages name the cause only, and refusalNextStep supplies the command. The only callers of stopProxy are ocx stop and the service manager's own cleanup, and by the time either reaches this point the service manager has already been asked to stop, so no branch answers with the command that just failed - every one points at `ocx service status`, which is what actually reports the wrapper. ProxyOwnershipRefusedError keeps its name. #4169 suggests renaming it and the name does overstate what it carries, but the carrying author deliberately left that out as a separate wider change and it is not part of the issue's expected behaviour. Closes #4169 Co-authored-by: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com> --- src/cli/index.ts | 13 +- src/lib/process-control.ts | 150 +++++++++++++++++--- tests/lib/process-control-graceful.test.ts | 151 ++++++++++++++++++++- tests/providers/xai/grok-lifecycle.test.ts | 25 +++- 4 files changed, 308 insertions(+), 31 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index d59f9e3257..2589f1eb5d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -68,7 +68,7 @@ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-li import { createReadinessGate } from "../server/readiness"; import { runReady, type ReadyArgs } from "./ready"; import { runCli } from "./root"; -import { isProcessAlive, ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control"; +import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control"; import { loadServiceTokenFromFile } from "../lib/service-secrets"; import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health"; @@ -952,7 +952,12 @@ async function handleStop() { if (detail) console.error(` ${detail}`); if (err instanceof ProxyOwnershipRefusedError) { ownershipBlocked = true; - console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running."); + // Every refusal `POST /api/stop` produces is written for an API client, so it + // recommends `ocx stop` — the command printing it. Following that advice returns + // the operator to this exact message, which is the loop #4169 was filed for. The + // service manager was already asked to stop above, so name what is actually left. + console.error(` ${refusalNextStep(err.code)}`); + console.error(" Skipping shared teardown (native Codex restore, Grok config): the refusing proxy is still running."); } } } else { @@ -979,7 +984,9 @@ async function handleStop() { if (detail) console.error(` ${detail}`); if (err instanceof ProxyOwnershipRefusedError) { ownershipBlocked = true; - console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running."); + // Same loop as the tracked-pid path above: the refusal recommends this command. + console.error(` ${refusalNextStep(err.code)}`); + console.error(" Skipping shared teardown (native Codex restore, Grok config): the refusing proxy is still running."); } } } else if (live) { diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 49da43987c..165d21aeaa 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -80,17 +80,96 @@ export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed"; */ let lastRefusalMessage: string | null = null; +/** + * The server's machine-readable reason for the most recent 409, captured alongside the + * message so a refusal that arrives without a body still names the right cause. Without it + * the fallback has to guess, and guessing "ownership" sent operators to re-check + * CODEX_HOME for a refusal the scheduler wrapper had issued (#4169). + */ +let lastRefusalCode: string | null = null; + /** The server's explanation for the most recent 409, or `null` when it sent none. */ export function lastStopRefusalMessage(): string | null { return lastRefusalMessage; } +/** The server's `code` for the most recent 409, or `null` when it sent none. */ +export function lastStopRefusalCode(): string | null { + return lastRefusalCode; +} + +/** + * Wording for a refusal whose body carried no message. Each branch mirrors a refusal the + * management API can return from `POST /api/stop`; the default stays cause-neutral because + * naming the wrong cause is worse than naming none — it costs the operator the time they + * spend acting on it. + * + * These name the cause only. The command belongs to {@link refusalNextStep}, because the + * only callers of `stopProxy` are `ocx stop` and the service manager's own cleanup, and a + * message that told either of them to run `ocx stop` would be the #4169 loop again. + */ +function refusalFallbackMessage(code: string | null): string { + switch (code) { + case "respawnable_service": + return "The running proxy refused to stop: a service manager that can respawn it owns " + + "the process."; + case "self_unload_service": + return "The running proxy refused to stop: it is the installed service itself, so " + + "stopping the manager from inside it would end the process before native Codex is " + + "restored."; + case "service_state_unknown": + return "The running proxy refused to stop: the service manager state could not be read, " + + "so it cannot tell whether a wrapper would respawn it."; + default: + return "The running proxy refused to stop and sent no reason."; + } +} + +/** + * What is actually left to do when `ocx stop` is the command that received the refusal. + * + * Every refusal `POST /api/stop` produces is written for an API client, so it recommends + * `ocx stop` — which is the command already running when the CLI prints it. That is the + * loop #4169 reports: the endpoint points at `ocx stop`, `ocx stop` repeats the endpoint, + * and neither names the wrapper that is refusing. `ocx stop` has already asked the service + * manager to stop by the time this is reached, so the remaining question is always what the + * service manager is doing, and no branch may answer with the command that just failed. + */ +export function refusalNextStep(code: string | null): string { + switch (code) { + case "respawnable_service": + return "This stop already asked the service manager to stop, so running `ocx stop` " + + "again is not the missing step. Run `ocx service status` to see whether a wrapper " + + "is still installed and able to respawn the proxy."; + case "self_unload_service": + return "This stop already asked the service manager to stop, so running `ocx stop` " + + "again is not the missing step. Run `ocx service status` to see whether the service " + + "is still registered."; + case "service_state_unknown": + return "Run `ocx service status` to see the query error, repair the service manager " + + "access, then retry."; + default: + return "Run `ocx service status` to inspect the service state."; + } +} + /** * A proxy declined shutdown (HTTP 409). There is more than one reason it can say no — a * scheduler wrapper under another home, or the proxy being the installed service itself * (#4023) — so the server's own message is carried through rather than guessed at. + * + * The refusal's `code` travels on the error because the reporting caller has to act on the + * cause, not re-parse prose: the message is the server's, and it recommends a command the + * CLI has already run. */ -export class ProxyOwnershipRefusedError extends Error {} +export class ProxyOwnershipRefusedError extends Error { + readonly code: string | null; + + constructor(message: string, code: string | null = null) { + super(message); + this.code = code; + } +} /** * Ask a running proxy to stop itself via the management API (`POST /api/stop`), which @@ -104,9 +183,28 @@ export class ProxyOwnershipRefusedError extends Error {} * attest the process exit code or completion of every drain/shutdown hook. */ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise { + return (await stopProxyGracefullyDetailed(pid, io)).result; +} + +/** + * The refusal a single stop attempt received, carried back to that attempt's caller. + * + * Module-scoped state cannot do this job: two overlapping stops race, and the first would + * report the second's cause. The exported accessors stay as observational state for callers + * that only want the last refusal, but the error text is built from this per-call value. + */ +type StopRefusal = { message: string | null; code: string | null }; + +async function stopProxyGracefullyDetailed( + pid: number, + io: GracefulStopIo = {}, +): Promise<{ result: GracefulStopResult; refusal: StopRefusal }> { + const refusal: StopRefusal = { message: null, code: null }; + const done = (result: GracefulStopResult): { result: GracefulStopResult; refusal: StopRefusal } => + ({ result, refusal }); const readRuntime = io.readRuntime ?? readRuntimePort; const runtime = io.runtimeEndpoint ?? readRuntime(pid); - if (!runtime?.port) return false; + if (!runtime?.port) return done(false); const env = io.env ?? process.env; const headers: Record = {}; const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv); @@ -128,20 +226,31 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): // longer than a health poll so we prefer drain over taskkill /F. signal: AbortSignal.timeout(io.exitTimeoutMs ? Math.min(io.exitTimeoutMs, 10_000) : 10_000), }); - // 409 is the proxy REFUSING to stop (a service installed under another home owns it and - // would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to - // SIGTERM here would run the daemon's cleanup and strip shared config out from under the + // 409 is the proxy REFUSING to stop. There is more than one reason it can say no — a + // respawning service manager, the proxy being the installed service itself, or an + // unreadable scheduler state — so both the message and the code are captured rather + // than assumed. That is a policy answer, not a dead endpoint — escalating to SIGTERM + // here would run the daemon's cleanup and strip shared config out from under the // still-running service. Report the refusal instead of forcing. if (res.status === 409) { - lastRefusalMessage = await res.json() + const parsed = await res.json() .then(body => { - const message = (body as { message?: unknown } | null)?.message; - return typeof message === "string" && message.trim() ? message.trim() : null; + const record = body as { message?: unknown; code?: unknown } | null; + const message = record?.message; + const code = record?.code; + return { + message: typeof message === "string" && message.trim() ? message.trim() : null, + code: typeof code === "string" && code.trim() ? code.trim() : null, + }; }) - .catch(() => null); - return "refused"; + .catch(() => ({ message: null, code: null })); + refusal.message = parsed.message; + refusal.code = parsed.code; + lastRefusalMessage = parsed.message; + lastRefusalCode = parsed.code; + return done("refused"); } - if (!res.ok) return false; + if (!res.ok) return done(false); const body: unknown = await res.json().catch(() => null); const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed"; sharedTeardownConfirmed = body !== null @@ -150,14 +259,14 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): && "success" in body && body.success === true && "sharedTeardown" in body && body.sharedTeardown === expectedTeardown; } catch { - return false; + return done(false); } const waitExit = io.waitExit ?? waitForExit; // Honor the server's own drain window: /api/stop answers 200 first, then drains for // config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain. const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs(); - if (!waitExit(pid, exitTimeoutMs)) return false; - return sharedTeardownConfirmed ? true : "teardown-unconfirmed"; + if (!waitExit(pid, exitTimeoutMs)) return done(false); + return done(sharedTeardownConfirmed ? true : "teardown-unconfirmed"); } function drainDeadlineMs(): number { @@ -172,14 +281,15 @@ function drainDeadlineMs(): number { export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise { if (!isProcessAlive(pid)) return false; const runtime = io.runtimeEndpoint ?? readRuntimePort(pid); - const graceful = await stopProxyGracefully(pid, io); + const { result: graceful, refusal } = await stopProxyGracefullyDetailed(pid, io); if (graceful === "refused") { - // The proxy refused on purpose (foreign service owns it). Forcing would strip shared - // config while that service keeps the proxy alive. + // The proxy refused on purpose. Forcing would strip shared config while whatever owns + // the process keeps it alive. The server's own message is preferred; the fallback is + // selected from its code so an empty body still names the right cause. Both come from + // THIS attempt, so an overlapping stop cannot lend it the wrong reason. throw new ProxyOwnershipRefusedError( - lastRefusalMessage - ?? "The running proxy refused to stop: a service installed under a different " - + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.", + refusal.message ?? refusalFallbackMessage(refusal.code), + refusal.code, ); } if (graceful === "teardown-unconfirmed") { diff --git a/tests/lib/process-control-graceful.test.ts b/tests/lib/process-control-graceful.test.ts index 9fad73d38b..9f1f2fe7fc 100644 --- a/tests/lib/process-control-graceful.test.ts +++ b/tests/lib/process-control-graceful.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { gracefulStopHost, lastStopRefusalMessage, stopProxyGracefully } from "../../src/lib/process-control"; +import { gracefulStopHost, lastStopRefusalCode, lastStopRefusalMessage, ProxyOwnershipRefusedError, refusalNextStep, stopProxy, stopProxyGracefully } from "../../src/lib/process-control"; function okResponse(): Response { return new Response(JSON.stringify({ success: true, sharedTeardown: "performed" }), { status: 200 }); @@ -198,4 +198,153 @@ describe("409 refusal reporting", () => { expect(result).toBe("refused"); expect(lastStopRefusalMessage()).toBeNull(); }); + + test("the refusal code is captured alongside the message", async () => { + // The message alone cannot drive the fallback: a refusal that arrives with an empty or + // unparseable body still has to name a cause, and #4169 showed what happens when the + // fallback guesses one — the operator re-checks CODEX_HOME for a refusal the scheduler + // wrapper issued. + await stopProxyGracefully(7, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response( + JSON.stringify({ success: false, code: "respawnable_service", message: "wrapper owns it" }), + { status: 409, headers: { "content-type": "application/json" } }, + )) as typeof fetch, + waitExit: () => true, + env: {}, + }); + expect(lastStopRefusalCode()).toBe("respawnable_service"); + + await stopProxyGracefully(7, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response("not json", { status: 409 })) as typeof fetch, + waitExit: () => true, + env: {}, + }); + expect(lastStopRefusalCode()).toBeNull(); + }); + + test("a refusal without a message falls back by code, never to an ownership claim", async () => { + const refusalFor = async (code: string | null): Promise => { + const body = code === null ? "not json" : JSON.stringify({ success: false, code }); + try { + await stopProxy(process.pid, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response(body, { + status: 409, + headers: { "content-type": "application/json" }, + })) as typeof fetch, + waitExit: () => { throw new Error("must not wait for a refused stop"); }, + env: {}, + }); + } catch (err) { + if (err instanceof ProxyOwnershipRefusedError) return err.message; + throw err; + } + throw new Error("stopProxy must throw on a refusal"); + }; + + const respawnable = await refusalFor("respawnable_service"); + expect(respawnable).toContain("respawn"); + // Not `ocx stop`: the only callers of stopProxy are `ocx stop` and the service + // manager's own cleanup, so recommending it here is the #4169 loop. The fallback names + // the cause and refusalNextStep names the command. + expect(respawnable).not.toContain("ocx stop"); + + const selfUnload = await refusalFor("self_unload_service"); + expect(selfUnload).toContain("installed service itself"); + expect(selfUnload).not.toContain("ocx stop"); + + const unknownState = await refusalFor("service_state_unknown"); + expect(unknownState).toContain("could not be read"); + expect(unknownState).not.toContain("ocx stop"); + + const noBody = await refusalFor(null); + expect(noBody).toContain("sent no reason"); + expect(noBody).not.toContain("ocx stop"); + + // None of them may assert the cause that #4169 was filed for. + for (const message of [respawnable, selfUnload, unknownState, noBody]) { + expect(message).not.toContain("CODEX_HOME"); + expect(message).not.toContain("OPENCODEX_HOME"); + } + }); + + test("the refusal carries its code to the caller that has to report it", async () => { + // The reporting caller acts on the cause. Re-parsing the prose is not an option: the + // message is the server's, and the server's message is exactly what recommends the + // command that already failed. + let thrown: unknown; + try { + await stopProxy(process.pid, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response( + JSON.stringify({ success: false, code: "respawnable_service", message: "wrapper owns it" }), + { status: 409, headers: { "content-type": "application/json" } }, + )) as typeof fetch, + waitExit: () => { throw new Error("must not wait for a refused stop"); }, + env: {}, + }); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(ProxyOwnershipRefusedError); + expect((thrown as ProxyOwnershipRefusedError).code).toBe("respawnable_service"); + // The server's own message still wins, unchanged. + expect((thrown as ProxyOwnershipRefusedError).message).toBe("wrapper owns it"); + }); + + test("no next step sends the operator back to the command that just refused", () => { + // #4169's loop: POST /api/stop answers "the stop must be run by `ocx stop`", and + // `ocx stop` reprints it. Whatever the cause, the next step has to be something else, + // because the service manager was already asked to stop before this point. + for (const code of ["respawnable_service", "self_unload_service", "service_state_unknown", null]) { + const step = refusalNextStep(code); + // Naming `ocx stop` in order to rule it out is the point; recommending it is the loop. + expect(step).not.toMatch(/Run `ocx stop`/); + expect(step).toContain("ocx service status"); + } + // The two service causes say why repeating the stop is not the missing step, since the + // server's message printed just above them recommends exactly that. + expect(refusalNextStep("respawnable_service")).toContain("already asked the service manager"); + expect(refusalNextStep("self_unload_service")).toContain("already asked the service manager"); + }); + + test("concurrent refusals each keep their own cause", async () => { + // Reading the reason from module state lets one stop publish its refusal and a second + // overwrite it before the first continuation consumes it. Starting both together is + // what actually reproduces that: verified against the pre-fix global handoff, where + // this schedule fails with the first call throwing the second's cause + // ("...it is the installed service itself..." for the respawnable_service stop). + // A schedule that lets one call finish entirely before resuming the other does NOT + // discriminate — the parked call republishes its own globals last and passes either way. + const refusalOf = (code: string) => async (): Promise => { + try { + await stopProxy(process.pid, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async () => new Response(JSON.stringify({ success: false, code }), { + status: 409, + headers: { "content-type": "application/json" }, + })) as typeof fetch, + waitExit: () => { throw new Error("must not wait for a refused stop"); }, + env: {}, + }); + } catch (err) { + if (err instanceof ProxyOwnershipRefusedError) return err.message; + throw err; + } + throw new Error("stopProxy must throw on a refusal"); + }; + + // Repeated because the interleaving is scheduler-dependent; the pre-fix code fails on + // the first iteration, but a single run would be a weak guard against reintroduction. + for (let i = 0; i < 20; i++) { + const [respawnable, selfUnload] = await Promise.all([ + refusalOf("respawnable_service")(), + refusalOf("self_unload_service")(), + ]); + expect(respawnable).toContain("respawn"); + expect(selfUnload).toContain("installed service itself"); + } + }); }); diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index 88212768af..e4d69b599f 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -150,8 +150,14 @@ describe("Grok fence lifecycle wiring", () => { // shared teardown must be skipped at both call sites, exactly like the service-manager path. const ownershipRefusals = stopFn.match(/err instanceof ProxyOwnershipRefusedError[\s\S]{0,200}?ownershipBlocked = true;/g); expect(ownershipRefusals).toHaveLength(2); - expect(stopFn.match(/Skipping shared teardown \(native Codex restore, Grok config\): the foreign proxy is still running\./g)).toHaveLength(2); + expect(stopFn.match(/Skipping shared teardown \(native Codex restore, Grok config\): the refusing proxy is still running\./g)).toHaveLength(2); expect(PROCESS_CONTROL_SOURCE).toContain("throw new ProxyOwnershipRefusedError("); + + // Both sites also print what is actually left to do. The refusal itself is written for + // an API client, so it recommends `ocx stop` — the command doing the printing — which + // is the loop #4169 reports. Echoing the server's message alone reproduces it. + expect(stopFn.match(/console\.error\(` \$\{refusalNextStep\(err\.code\)\}`\);/g)).toHaveLength(2); + expect(PROCESS_CONTROL_SOURCE).toContain("export function refusalNextStep("); }); test("handleStop returns its outcome while both restart surfaces share the in-place lifecycle", () => { @@ -507,10 +513,12 @@ describe("POST /api/stop teardown", () => { }); test("a 409 does not escalate to a forced kill", () => { - // Escalating would run the daemon's cleanup and strip shared config while the foreign - // service keeps the proxy alive — the exact hole the ownership gate exists to close. + // Escalating would run the daemon's cleanup and strip shared config while the refusing + // service keeps the proxy alive — the exact hole the refusal gate exists to close. // The 409 branch may capture the server's reason first (#4023 added a second refusal - // cause), but it must still return "refused" without falling through to !res.ok. + // cause, #4169 the code that names it), but it must still yield "refused" without + // falling through to !res.ok. Matched loosely so a wrapped return (`done("refused")`) + // still satisfies the invariant this guards, which is ordering, not spelling. const stopGracefully = sliceFn( PROCESS_CONTROL_SOURCE, "export async function stopProxyGracefully(", @@ -518,9 +526,12 @@ describe("POST /api/stop teardown", () => { ); const four09At = stopGracefully.indexOf("res.status === 409"); expect(four09At).toBeGreaterThan(-1); - expect(stopGracefully.slice(four09At)).toContain('return "refused"'); - expect(stopGracefully.indexOf('return "refused"', four09At)) - .toBeLessThan(stopGracefully.indexOf("if (!res.ok) return false;", four09At)); + const refusedReturn = /return (?:done\()?"refused"/; + const okFallthrough = /if \(!res\.ok\) return (?:done\()?false/; + const afterFour09 = stopGracefully.slice(four09At); + expect(afterFour09).toMatch(refusedReturn); + expect(afterFour09.search(refusedReturn)) + .toBeLessThan(afterFour09.search(okFallthrough)); const stopProxyFn = sliceFn(PROCESS_CONTROL_SOURCE, "export async function stopProxy(", "export function killProxy("); const refusedAt = stopProxyFn.indexOf('graceful === "refused"'); From 9e4654afb046cf50e8c658b2eb00f14907c2b74e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:24:09 +0900 Subject: [PATCH 051/126] docs(devlog): record the wp2 stop-refusal work-phase --- .../020_wp2_stop_refusal.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 devlog/_plan/260911_l4_service_cli/020_wp2_stop_refusal.md diff --git a/devlog/_plan/260911_l4_service_cli/020_wp2_stop_refusal.md b/devlog/_plan/260911_l4_service_cli/020_wp2_stop_refusal.md new file mode 100644 index 0000000000..06962ff39b --- /dev/null +++ b/devlog/_plan/260911_l4_service_cli/020_wp2_stop_refusal.md @@ -0,0 +1,87 @@ +# wp2 — #4169: every stop refusal is reported as a CODEX_HOME ownership mismatch + +Work-phase 2 of the L4 lane, stacked on wp1. Carried source: PR #4170 by `yeongjunyoo`, +head `4d72ef0103`, three commits on base `c15a98caa9`. All four of its files are inside this +lane's ownership, so nothing was dropped. + +## What the carry does + +`POST /api/stop` refuses for three distinct reasons. `stopProxy` preferred the server's own +message but fell back, when none was readable, to one hardcoded sentence naming a fourth cause +the server never reports. #4170 captures the refusal `code`, selects the fallback wording from +it, and returns the refusal per attempt instead of publishing it to module state — the last of +those fixes a real interleaving bug where two overlapping stops could swap causes. + +## What the carry left open + +#4169's **Expected** section asks for two things: + +1. a `respawnable_service` or `service_state_unknown` refusal must not be described as an + ownership mismatch, and +2. *"the recommended next command should not be the command the operator just ran."* + +The carry does (1). Subagent Heisenberg traced (2) and returned `LOOP_REAL`: + +- `ocx stop` reaches `POST /api/stop` through `dispatch.stop` → `handleStop` → + `stopWithDeferral` → `stopProxy` → `stopProxyGracefully`. Stopping the service manager + first does not skip it. +- The server answers with *"the stop must be run by `ocx stop`"* because that refusal is + written for an API client, and `management-api.ts` emits `respawnable_service` precisely + when the CLI's teardown receipt was **not** honoured, so it cannot tell the two apart. +- `handleStop` prints `err.message` verbatim, so the operator is told to run the command they + are already running. +- The carried fallback for `respawnable_service` also ends in "Run `ocx stop`", making the + empty-body path a tighter loop than the one being fixed. + +There is no header, flag, query parameter or route that marks a CLI-originated stop, so the +server cannot word the refusal differently. The correction belongs to the CLI, which is the one +caller that knows which it is. + +## What this work-phase adds + +- The refusal `code` travels on `ProxyOwnershipRefusedError`. The reporting caller acts on the + cause; re-parsing the prose is not an option, because the prose is the server's. +- `refusalFallbackMessage` names the cause only. +- A new exported `refusalNextStep(code)` owns the command, and `handleStop` prints it under the + refusal at both call sites. The only callers of `stopProxy` are `ocx stop` and the service + manager's own cleanup, and both have already asked the service manager to stop by then, so no + branch answers with the command that just failed. + +`ProxyOwnershipRefusedError` keeps its name. The issue suggests renaming it and the name does +overstate what it carries, but the carrying author deliberately deferred that as a separate +wider change and it is not part of the issue's Expected behaviour. Recorded, not decided +unilaterally. + +## Audit + +Subagent Confucius reviewed the staged diff adversarially and returned `BLOCKERS_FOUND` with one +item: the new next-step test banned the literal `` `ocx stop` ``, which the production wording +contains **in order to rule it out**, so the test failed against its own implementation. Folded: +the assertion now bans a recommendation (`/Run \`ocx stop\`/`) rather than a mention. Its second +observation — that no test pinned the CLI wiring — is folded as a source oracle over the two +`refusalNextStep(err.code)` print sites. + +Re-checked deterministically afterwards: the four source-oracle counts in +`grok-lifecycle.test.ts` are all exactly 2, and no `refusalNextStep` branch matches +`/Run \`ocx stop\`/`. + +## CI repair carried into this phase + +Hosted CI on wp1's head failed on Linux and macOS with one test: +`codex-cli-update-launcher-policy` asserts `bin/ocx.mjs` contains +`"!codexCliUpdateInspection && isNodeModulesInstall()"` as an adjacent string. The carry gates +the #1849 boot probe on the npm layout, inserting `installMethod === "npm"` between those two +clauses. The invariant the oracle protects is intact and still evaluated first; only the +adjacency changed. The oracle now locates the guard wrapping the `bootRestoreProbe` call and +asserts both clauses are in it. + +`tests/codex-integration/codex-cli-update-launcher-policy.test.ts` is not in the packet's +keep-set. It is an oracle over `bin/ocx.mjs`, which this lane owns, and the round already +granted L2 the same thing for the same reason: a lane that changes a file owns the oracle +asserting it, or the change cannot land at all. Reported rather than assumed. + +## Not run + +`bun test`, `bun run test`, `bun run test:changed`, `bun run typecheck`, `bun run build:gui` +and `bun install` are NOT RUN by operator instruction. Hosted CI on the exact pushed head is +the only product evidence this round accepts. From 6db526a8a17f437e7035dca1b810ea391c5d26e5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:37:16 +0900 Subject: [PATCH 052/126] docs(providers): state which account each provider login spends The guide never answered the first question a subscriber asks: does connecting this login draw on the subscription it already pays for, or bill a separate API account. Asked on X for Claude Max, and it recurs for every provider that offers both. The rule is stated per authMode rather than per vendor tier, followed by one row for each of the eight providers that accept both a login and a key, and a pointer at the Connection block's Authentication row so a reader can check their own setup. Two claims the issue proposed are not true and are not written. OpenCodex does convert an OAuth login into an API key in one case: orcarouter-oauth mints a user-owned sk-orca key by design. And an API key does not always bill per token, because Z.AI GLM Coding Plan, Kimi Code, the BigModel coding plan, Command Code and CodeBuddy all sell a subscription as a key. What is true, and what the section says, is that a single request uses exactly one credential family and never falls back across them. The issue also asked the guide to point at the dashboard account card. No per-account auth-mode badge exists; the mode is a provider-level field on the Connection block, so the section names the surface that is actually there. Closes #4215 --- .../src/content/docs/guides/providers.md | 53 ++++++++ scripts/test-layout/layout.json | 1 + .../docs-provider-billing-claims.test.ts | 119 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 174 insertions(+) create mode 100644 tests/ci-workflows/docs-provider-billing-claims.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0986a883c0..d4a0f1c724 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -81,6 +81,59 @@ credentials must never be replayed on the same token, and local runtimes have no preserve. It is opt-in: when the option is absent the feature is off; object presence enables it unless `enabled: false`. +### Which account a request spends + +The question people ask before connecting an account is whether opencodex will draw on the +subscription that login already pays for, or bill a separate API account. The answer follows the +`authMode` above rather than the vendor's marketing tier. + +- `forward` — the ChatGPT login. The request carries your Codex credential, so it spends the + ChatGPT plan behind that login and reports that plan's Codex quota windows. Which windows exist + is plan-dependent: not every plan has a five-hour window. It never reads an API key. +- `oauth` — a subscription login. The request carries a stored access token, so it spends whichever + account you logged in as, and opencodex reports whichever usage windows that provider exposes. +- `key` — the request carries the key you supplied, so usage lands on the account that owns that + key, on that key's own terms. That is metered usage for a pay-as-you-go API account, but a plan + allowance when the key *is* a subscription: Z.AI GLM Coding Plan, Kimi Code, the BigModel coding + plan, Command Code and CodeBuddy all sell one that way. + +A request uses exactly one of these, and opencodex does not fall back from one to the other. When an +OAuth credential cannot be resolved the request fails with an authentication error instead of +reaching for a stored key, and the key-pool failover that answers a 429 or a 401 is refused outright +for OAuth and forward providers. + +Two exceptions are worth knowing because you can hit them: + +- `xai` and `github-copilot` accept `authMode: "key"` on the same provider id, and if that provider + already had a key stored, running `ocx login` for it can leave it in key mode rather than + switching it to the subscription. What that changes differs: an `xai` key retargets the provider + to `https://api.x.ai/v1`, so a different account pays, while a `github-copilot` key is still a + Copilot credential against `api.githubcopilot.com`, so the Copilot subscription pays either way. +- `orcarouter-oauth` is a consent flow that mints a user-owned `sk-orca-…` API key. Once it has, + the request carries a key, so it follows the `key` rule above. + +#### Providers that accept both a login and a key + +| Provider | Subscription login | API key | +| --- | --- | --- | +| OpenAI / ChatGPT | `openai` — Codex login; spends the ChatGPT plan behind it | `openai-apikey` — a separate provider; usage lands on the OpenAI Platform account that owns the key | +| Anthropic | `ocx login anthropic` — signs in as your Claude account. opencodex reads its five-hour and seven-day usage windows; that endpoint reports no subscription tier | `anthropic-apikey` — direct Anthropic API billing, no Claude subscription | +| xAI | `ocx login xai` — the Grok CLI subscription gateway. opencodex reads SuperGrok weekly credits, or the monthly pool | the same `xai` provider with `authMode: "key"`, which targets `https://api.x.ai/v1`, so usage lands on that API account | +| Kimi | `ocx login kimi` — log in with your Kimi account | `kimi-code` — the API-key form of the same Kimi Code Plan transport | +| Command Code | `ocx login command-code` — opencodex reads five-hour and weekly windows plus a credit balance | `commandcode` — the same service on `/provider/v1` with a key | +| GitHub Copilot | `ocx login github-copilot` — requires an active Copilot subscription | the same `github-copilot` provider with `authMode: "key"`. The device flow above is the supported path, and either credential is a Copilot one, so the subscription still pays | +| OrcaRouter | `ocx login orcarouter-oauth` — consent mints a user-owned, long-lived `sk-orca-…` key, and the request then carries a key | `orcarouter` — the same key pasted by hand | +| Meta Muse | `ocx login meta-muse` imports the Muse Code CLI key. Meta scopes that credential to its own CLI, so this is an unsupported use: how the calls settle is not observable from the API, and you should treat every call as billable against your account | `meta-model` is the supported path — every call is metered per token, and a Muse Code subscription does not work there | + +Cursor, Kiro and Nous Portal are login-only and have no API-key equivalent. Google Antigravity is +login-only too: `ocx login google-antigravity` signs in with your Google account over the Cloud Code +Assist wire, and the `google` preset beside it is the AI Studio Gemini API — a different product +reached with its own key, not a key mode for the same login. + +To check which mode a provider is actually using, open it on the Providers page: the **Connection** +block's **Authentication** row reads `OAuth`, `API key`, `ChatGPT passthrough`, `Local`, or +`No key needed`. It is a provider-level setting, so the account rows below it do not repeat it. + ## 1. ChatGPT login (forward / passthrough) The `openai` provider needs **no API key**. Direct forwards credentials from your existing diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..211e1f5c07 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -613,6 +613,7 @@ "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", + "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", "doctor-oauth.test.ts": "service", diff --git a/tests/ci-workflows/docs-provider-billing-claims.test.ts b/tests/ci-workflows/docs-provider-billing-claims.test.ts new file mode 100644 index 0000000000..831202aea4 --- /dev/null +++ b/tests/ci-workflows/docs-provider-billing-claims.test.ts @@ -0,0 +1,119 @@ +/** + * The providers guide must keep answering "which account does this login spend?". + * + * The question arrives from users faster than the docs answer it (#4215), and the two obvious + * ways to answer it are both wrong. "OpenCodex never converts a subscription login into an API + * key" is false -- orcarouter-oauth mints a user-owned sk-orca- key by design. "An API key bills + * per token" is false too -- Z.AI GLM Coding Plan, Kimi Code, the BigModel coding plan, Command + * Code and CodeBuddy all sell a subscription as a key. Both sentences read as obviously true, + * which is exactly why a future edit will reach for them again. + * + * So this pins the shape that survived three source audits: a rule stated per authMode, one row + * per provider that accepts both, and a pointer at the dashboard surface that actually renders + * the mode. The per-provider rows rot first, because a new dual-mode preset lands in the registry + * without anyone reopening this guide. + */ +import { describe, expect, test } from "bun:test"; +import { repoPath } from "../helpers/repo-root"; + +const GUIDE = repoPath("docs-site/src/content/docs/guides/providers.md"); +const HEADING = "### Which account a request spends"; + +/** The subsection only, so a marker that also appears in the API-key catalog cannot satisfy it. */ +async function billingSection(): Promise { + const source = await Bun.file(GUIDE).text(); + const start = source.indexOf(HEADING); + expect(start, `providers.md lost the "${HEADING}" section`).toBeGreaterThanOrEqual(0); + const rest = source.slice(start); + const end = rest.indexOf("\n## "); + return end === -1 ? rest : rest.slice(0, end); +} + +/** + * Each provider that accepts a subscription login AND an API key. Both markers must appear on that + * provider's row: a row naming only one mode leaves the reader guessing about the other, which is + * the failure #4215 reported. + */ +const DUAL_MODE = [ + { label: "OpenAI / ChatGPT", login: "`openai`", key: "`openai-apikey`" }, + { label: "Anthropic", login: "ocx login anthropic", key: "`anthropic-apikey`" }, + { label: "xAI", login: "ocx login xai", key: '`authMode: "key"`' }, + { label: "Kimi", login: "ocx login kimi", key: "`kimi-code`" }, + { label: "Command Code", login: "ocx login command-code", key: "`commandcode`" }, + { label: "GitHub Copilot", login: "ocx login github-copilot", key: '`authMode: "key"`' }, + { label: "OrcaRouter", login: "ocx login orcarouter-oauth", key: "`orcarouter`" }, + { label: "Meta Muse", login: "ocx login meta-muse", key: "`meta-model`" }, +] as const; + +describe("provider billing claims", () => { + test("the rule is stated per authentication mode, not per vendor tier", async () => { + const section = await billingSection(); + + // forward: the ChatGPT plan, and the windows are plan-dependent. The unqualified version of + // this sentence promised every plan a five-hour window, which is not true. + expect(section).toContain("plan-dependent"); + expect(section).toContain("It never reads an API key."); + + // key: the account that owns the key, on that key's own terms -- which may be a plan. + expect(section).toContain("usage lands on the account that owns that"); + expect(section).toContain("but a plan"); + + // No cross-family fallback, stated as a failure rather than as a silent swap. + expect(section).toContain("fails with an authentication error"); + }); + + test("both shipped exceptions to the rule are stated", async () => { + const section = await billingSection(); + // A key persisted on an OAuth preset survives login, so logging in does not necessarily move + // billing to the subscription. + expect(section).toContain('`authMode: "key"` on the same provider id'); + // The consent flow that really does hand back an API key. + expect(section).toContain("sk-orca-"); + + // Copilot accepts a key on the same provider id, but that key is still a Copilot credential. + // The guide says elsewhere that the device flow is the supported path, so this section must not + // imply a key moves Copilot billing to some other account. + expect(section).toContain("the subscription still pays"); + }); + + test("every dual-mode provider names both of its modes", async () => { + const section = await billingSection(); + const rows = section.split("\n").filter(line => line.startsWith("| ")); + for (const provider of DUAL_MODE) { + const row = rows.find(line => line.startsWith(`| ${provider.label} |`)); + expect(row, `no row for ${provider.label}`).toBeDefined(); + expect(row, `${provider.label} row lost its subscription login`).toContain(provider.login); + expect(row, `${provider.label} row lost its API key`).toContain(provider.key); + } + }); + + test("the login-only providers are named so their absence is not read as an omission", async () => { + const section = await billingSection(); + expect(section).toContain("Cursor, Kiro and Nous Portal are login-only"); + // Antigravity is the provider a reader is most likely to mistake for a dual-mode one, because + // a `google` preset sits beside it in the catalog. It is a different product, so it belongs + // here rather than in the table above -- a row there would contradict its own heading. + expect(section).toContain("Google Antigravity is"); + expect(section).toContain("not a key mode for the same login"); + }); + + test("the reader is pointed at a dashboard surface that exists", async () => { + // #4215 asked for "the account card", which carries no auth-mode badge: the mode is a + // provider-level field on the Connection block. Sending a reader to the account rows would + // have them hunting for something that was never built. + const section = await billingSection(); + expect(section).toContain("**Authentication** row"); + for (const label of ["`OAuth`", "`API key`", "`ChatGPT passthrough`", "`Local`", "`No key needed`"]) { + expect(section, `the Authentication row no longer lists ${label}`).toContain(label); + } + expect(section).toContain("the account rows below it do not repeat it"); + }); + + test("the refuted absolute claim does not come back", async () => { + // Both phrasings are the ones a well-meaning edit reaches for, and both are false while + // orcarouter-oauth ships. + const source = await Bun.file(GUIDE).text(); + expect(/never converts/i.test(source)).toBe(false); + expect(source).not.toContain("does not convert one into the other"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..02a8b205d5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -448,6 +448,7 @@ "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", + "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", "doctor-oauth.test.ts": "service", From ee61b4822a8cfb67fc8fd8d779c84af5eb9e775c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:38:28 +0900 Subject: [PATCH 053/126] docs(devlog): record the #4215 delivery and what three audits changed --- .../_plan/260911_l7_docs/040_4215_delivery.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 devlog/_plan/260911_l7_docs/040_4215_delivery.md diff --git a/devlog/_plan/260911_l7_docs/040_4215_delivery.md b/devlog/_plan/260911_l7_docs/040_4215_delivery.md new file mode 100644 index 0000000000..fbaeb6b1d9 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/040_4215_delivery.md @@ -0,0 +1,68 @@ +# #4215 — delivery record + +Commit `6db526a8a1` on `codex/260911-l7-docs`. Closes #4215. + +## What shipped + +`docs-site/src/content/docs/guides/providers.md` gains a `### Which account a request spends` +subsection under the existing **Auth modes** heading: the rule per `authMode`, the two shipped +exceptions, a table of the eight providers that accept both a login and a key, a login-only line, +and a pointer at the Connection block's **Authentication** row. + +`tests/ci-workflows/docs-provider-billing-claims.test.ts` guards it, registered in +`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## What the audits changed + +Three rounds, and each one changed the shipped text rather than merely approving it. + +**Round 1 rejected the issue's own wording.** "OpenCodex does not convert one into the other" is +false while `orcarouter-oauth` ships, and "an API key bills per token" is false for the several +presets that sell a subscription as a key. Both are now stated as what is actually true: a request +uses one credential family and never falls back across them. + +**Round 2 rejected the framing itself.** Revision 2 wrote "spends the Claude subscription +allowance" and cited a registry line. The registry states an id, a transport and an auth kind; it +does not state vendor billing terms, and `quota.ts:1404`-`1409` says the Anthropic endpoint reports +no tier at all. The guide now answers in terms the source can support — which credential the request +carries, which account it authenticates as, which windows opencodex reads back — and says once that +the billing terms belong to the vendor. + +**Round 3 caught two contradictions inside the shipped diff.** + +- The table was headed "Providers that accept both" and carried a Google Antigravity row whose own + text said it has no key mode. The heading now reads "Providers that accept both a login and a + key", and Antigravity moved to the login-only paragraph, where its neighbour `google` is named as + a different product rather than a key mode for the same login. The test's `DUAL_MODE` list lost + that entry, so CI no longer pins a row that refutes its own heading. +- The GitHub Copilot row implied that choosing a key changes which account pays, which fights + `providers.md:909` — Copilot exchanges a device-flow login for a short-lived Copilot token, not a + pasted API key. The row and the exceptions bullet now separate the two cases: an `xai` key + retargets the provider to `https://api.x.ai/v1` so a different account pays, while a + `github-copilot` key is still a Copilot credential against `api.githubcopilot.com`, so the + subscription pays either way. + +## Decisions this lane made + +- **Form.** Rule first, then one row per dual-mode provider, as the packet directed. A table + carries the rows because the reader's question is a lookup. +- **Scope.** Dual-mode providers only. A provider with one mode is already unambiguous. +- **Verification surface.** The issue asked the guide to point at "the account card". No + per-account auth-mode badge exists (`ProviderAuthPanel.tsx:504`-`527`); the mode is a + provider-level field, so the guide points at the Connection block's **Authentication** row and + names the five labels it renders, including the `No key needed` fallback. +- **Copilot stays in the table** even though both of its modes spend the same subscription, because + it genuinely accepts both credential forms and a reader who sees `authMode: "key"` in a config + needs to know it does not move the bill. + +## Verification + +The local product suite was NOT RUN by operator instruction: no `bun test`, no `bun run test`, no +`bun run test:changed`, no `bun run typecheck`, no `bun run build:gui`, no `bun install`. Hosted CI +on the exact pushed head is the proof. + +Because the new test could not be executed locally, a read-only subagent verified it by reading: +every `toContain`, `not.toContain` and regex literal was located in `providers.md`, checked against +the section-slice boundary, and confirmed to sit in the cell the assertion intends — including the +backtick delimiters that stop a login marker from matching a key cell. That check was re-run from +scratch after the table lost a row. From 9627fe2d611fb16d1ee8ede4f79750395bc0e918 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:41:27 +0900 Subject: [PATCH 054/126] fix(client): refuse a hub catalog the local Codex CLI cannot parse A connected client reported connected, catalog present, token owned and rotation clean, while its installed Codex CLI exited before making a single request: failed to parse model_catalog_json ... unknown variant `max`, expected one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` The connection state answered a different question from the one the operator was asking. It proved the hub was reachable and the credential worked; nothing on that path proved the selected local runtime could consume what was written. validateRemoteCatalog checks JSON shape only, and connect wrote the hub's bytes verbatim, so the effort clamp that already exists for local catalog sync never saw the downloaded file. The client path now establishes compatibility before it materialises anything. catalogEffortCompatibility reports which reasoning levels the observed local ladder would reject, without mutating the catalog - the clamp beside it is mutate-and-continue, which is right when this process owns the file it is about to write and wrong for a catalog that must keep agreeing with hub truth. Both hub-download writes, connect and sync, are gated on it and fail closed: the download is refused before the write, so the previous known-good catalog is still there, no connection is committed, and no success is printed. The two restore paths stay ungated, since refusing to restore a catalog this machine already accepted would strand the client with none at all. The refusal names the level, what was preserved, and both ways out - upgrade the Codex CLI, or point CODEX_CLI_PATH at a build that supports it and run `ocx sync`. It never suggests editing the hub catalog and it does not touch running Codex processes, both of which the issue rules out. An unobservable local ladder is reported as unverified rather than incompatible, and does not block. A client machine may legitimately have no Codex CLI to observe, and refusing on absent evidence would break a working configuration; the issue asks to preserve the prior catalog when compatibility cannot be established, which is the incompatible case, not the inconclusive one. That call is mine and is recorded here. Closes #4207 --- scripts/test-layout/layout.json | 1 + src/client/catalog-compatibility.ts | 107 ++++++++++++++ src/client/connect.ts | 12 ++ src/codex/catalog/effort.ts | 41 ++++++ .../client-catalog-compatibility.test.ts | 139 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 6 files changed, 301 insertions(+) create mode 100644 src/client/catalog-compatibility.ts create mode 100644 tests/clients/client-catalog-compatibility.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 135aeaba5c..83057fdd24 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -360,6 +360,7 @@ "cli-transport-honesty.test.ts": "cli", "cli-usage-report.test.ts": "cli", "cli-version-skew.test.ts": "cli", + "client-catalog-compatibility.test.ts": "clients", "client-config-export-new-clients.test.ts": "config", "client-config-export.test.ts": "config", "client-config-new-clients.test.ts": "config", diff --git a/src/client/catalog-compatibility.ts b/src/client/catalog-compatibility.ts new file mode 100644 index 0000000000..8b4b9011cf --- /dev/null +++ b/src/client/catalog-compatibility.ts @@ -0,0 +1,107 @@ +/** + * #4207: a connected client reported `connected` with a present, freshly synced catalog while + * its installed Codex CLI exited before making a request, because the hub's catalog contained a + * reasoning level that CLI does not know: + * + * failed to parse model_catalog_json ... unknown variant `max`, + * expected one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` + * + * The connection state answered a different question from the one the operator was asking. It + * proved the hub was reachable and the credential worked; it never proved the selected local + * runtime could consume what was downloaded. This module supplies the missing half, and the + * connect path fails closed on it: an incompatible catalog is refused before it is written, so + * the previous known-good file survives and no success is reported. + * + * What it deliberately does not do: rewrite the hub's catalog into a locally compatible + * projection (the client would then silently disagree with hub truth) and terminate running + * Codex processes. Both are ruled out by the issue. + */ +import { catalogEffortCompatibility, codexSupportedReasoningEfforts } from "../codex/catalog/effort"; +import type { RawEntry } from "../codex/catalog/parsing"; + +export type ClientCatalogCompatibility = + | { kind: "compatible" } + /** The runtime ladder could not be observed, so incompatibility cannot be established. */ + | { kind: "unverified"; reason: string } + | { + kind: "incompatible"; + unsupportedEfforts: readonly string[]; + affectedModels: readonly string[]; + }; + +export interface CatalogCompatibilityDeps { + /** Injected in tests; defaults to observing the selected local Codex runtime. */ + supportedEfforts?: () => ReadonlySet | null; +} + +function parseModels(body: string): RawEntry[] | null { + try { + const parsed = JSON.parse(body) as { models?: unknown }; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return Array.isArray(parsed.models) ? parsed.models as RawEntry[] : []; + } catch { + return null; + } +} + +/** + * Assess a downloaded catalog against the reasoning efforts the selected local Codex runtime + * accepts. Unreadable bytes are reported as unverified rather than incompatible: the hub + * client already rejects a malformed body, and inventing a second cause for it here would + * repeat the mistake #4169 was filed for. + */ +export function assessClientCatalogCompatibility( + body: string, + deps: CatalogCompatibilityDeps = {}, +): ClientCatalogCompatibility { + const models = parseModels(body); + if (!models) return { kind: "unverified", reason: "the downloaded catalog could not be read" }; + const supported = (deps.supportedEfforts ?? (() => codexSupportedReasoningEfforts()))(); + if (!supported) { + return { + kind: "unverified", + reason: "the selected local Codex runtime did not report the reasoning levels it supports", + }; + } + const result = catalogEffortCompatibility(models, supported); + if (result.compatible) return { kind: "compatible" }; + return { + kind: "incompatible", + unsupportedEfforts: result.unsupportedEfforts, + affectedModels: result.affectedModels, + }; +} + +/** + * Raised instead of writing an incompatible catalog. It names both remedies the issue asks + * for, because the operator cannot act on "incompatible" alone, and it never suggests editing + * the hub. + */ +export class ClientCatalogIncompatibleError extends Error { + readonly unsupportedEfforts: readonly string[]; + readonly affectedModels: readonly string[]; + + constructor(unsupportedEfforts: readonly string[], affectedModels: readonly string[]) { + const efforts = unsupportedEfforts.join(", "); + const models = affectedModels.length > 3 + ? `${affectedModels.slice(0, 3).join(", ")} and ${affectedModels.length - 3} more` + : affectedModels.join(", "); + super( + `catalog_incompatible: the hub catalog uses reasoning ${unsupportedEfforts.length === 1 ? "level" : "levels"} ` + + `${efforts}, which the selected local Codex CLI rejects${models ? ` (${models})` : ""}. ` + + "The previous catalog was kept and nothing was changed. Upgrade the Codex CLI to a " + + "version that supports those levels, or point CODEX_CLI_PATH at one that does and run " + + "`ocx sync`, then retry. `ocx doctor` reports which runtime is selected.", + ); + this.name = "ClientCatalogIncompatibleError"; + this.unsupportedEfforts = unsupportedEfforts; + this.affectedModels = affectedModels; + } +} + +/** Fail closed: refuse an incompatible catalog before anything is written. */ +export function assertClientCatalogCompatible(body: string, deps: CatalogCompatibilityDeps = {}): void { + const assessment = assessClientCatalogCompatibility(body, deps); + if (assessment.kind !== "incompatible") return; + throw new ClientCatalogIncompatibleError(assessment.unsupportedEfforts, assessment.affectedModels); +} diff --git a/src/client/connect.ts b/src/client/connect.ts index 2b0e8efe08..3569f83c83 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -67,6 +67,7 @@ import { readClientConnectionState, assertNoClientDisconnectPending, assertClientConnectionUnchanged, sameClientConnectionOwner, } from "./state"; +import { assertClientCatalogCompatible, type CatalogCompatibilityDeps } from "./catalog-compatibility"; class RotationRecoveryRequiredError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -89,6 +90,7 @@ export interface ClientConnectDeps { fetchImpl?: typeof fetch; now?: () => Date; lifecycleLockDeps?: ClientLifecycleLockDeps; + catalogCompatibility?: CatalogCompatibilityDeps; } export interface RotateClientOptions { @@ -543,6 +545,12 @@ export async function connectClient( fetchImpl: deps.fetchImpl, timeoutMs: options.catalogTimeoutMs, }); + // Fail closed BEFORE the write (#4207). The hub being reachable and the credential working + // does not mean the selected local Codex runtime can consume what arrived: an older CLI + // exits on an unknown reasoning level before making a single request, while connect + // reports success. Refusing here leaves the previous catalog in place untouched, rather + // than writing one and restoring it afterwards. + assertClientCatalogCompatible(catalog.body, deps.catalogCompatibility); writtenCatalogFingerprint = withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertConnectingState(persisted.fingerprint); atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body); @@ -659,6 +667,10 @@ export async function syncConnectedClient( if (!transient) throw error; stale = true; } + // Same gate as connect (#4207): a sync must never replace a catalog the local CLI can parse + // with one it cannot. Refusing leaves the connection and the existing catalog exactly as + // they were, which is the known-good state. + if (downloaded) assertClientCatalogCompatible(downloaded.body, deps.catalogCompatibility); const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertClientConnectionUnchanged(initial.connection); const token = readServiceApiTokenState(); diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 491518cda6..ba324f12a3 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -389,6 +389,47 @@ export interface ObservedCatalogEffortClamp { readonly affectedModels: readonly string[]; } +export interface CatalogEffortCompatibility { + readonly compatible: boolean; + readonly unsupportedEfforts: readonly string[]; + readonly affectedModels: readonly string[]; +} + +/** + * Report which reasoning efforts in a catalog the local Codex runtime would reject, without + * changing anything. + * + * The clamp above is mutate-and-continue, which is right when this process owns the file it + * is about to write. It is wrong for a catalog downloaded from a hub: rewriting it locally + * would make the client disagree with hub truth, and #4207 asks for the opposite — establish + * compatibility first, and refuse rather than materialise a catalog the local CLI cannot + * parse. `supported` of null means the runtime ladder could not be observed, which is not + * evidence of incompatibility, so nothing is reported. + */ +export function catalogEffortCompatibility( + models: readonly RawEntry[], + supported: ReadonlySet | null, +): CatalogEffortCompatibility { + if (!supported) return { compatible: true, unsupportedEfforts: [], affectedModels: [] }; + const unsupported = new Set(); + const affected: string[] = []; + for (const entry of models) { + const rejected = catalogEntryEfforts(entry).filter(effort => !supported.has(effort)); + const fallback = typeof entry.default_reasoning_level === "string" + && !supported.has(entry.default_reasoning_level) + ? [entry.default_reasoning_level] + : []; + if (rejected.length === 0 && fallback.length === 0) continue; + for (const effort of [...rejected, ...fallback]) unsupported.add(effort); + if (typeof entry.slug === "string") affected.push(entry.slug); + } + return { + compatible: unsupported.size === 0, + unsupportedEfforts: [...unsupported].sort(), + affectedModels: affected, + }; +} + /** Apply an already-observed runtime ladder without probing, logging, or writing diagnostics. */ export function clampCatalogModelsToObservedCodexSupport( models: RawEntry[], diff --git a/tests/clients/client-catalog-compatibility.test.ts b/tests/clients/client-catalog-compatibility.test.ts new file mode 100644 index 0000000000..f01f43d071 --- /dev/null +++ b/tests/clients/client-catalog-compatibility.test.ts @@ -0,0 +1,139 @@ +/** + * #4207: `ocx connect status` reported connected, catalog present and freshly synced, while + * the installed Codex CLI exited before its first request because the downloaded catalog used + * a reasoning level it does not know. Connection state proved the hub and the credential; it + * never proved the selected local runtime could consume what was written. + * + * The gate fails closed: an incompatible catalog is refused before the write, so the previous + * known-good file survives. It does not rewrite the hub's catalog into a local projection and + * it does not touch running Codex processes. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { catalogEffortCompatibility } from "../../src/codex/catalog/effort"; +import { + assertClientCatalogCompatible, + assessClientCatalogCompatibility, + ClientCatalogIncompatibleError, +} from "../../src/client/catalog-compatibility"; +import { repoPath } from "../helpers/repo-root"; + +/** The shape the hub publishes: a model row with a reasoning ladder. */ +function catalogBody(levels: string[], slug = "gpt-5.6-sol", defaultLevel?: string): string { + return JSON.stringify({ + models: [{ + slug, + supported_reasoning_levels: levels.map(effort => ({ effort })), + ...(defaultLevel ? { default_reasoning_level: defaultLevel } : {}), + }], + }); +} + +/** Codex CLI 0.135.0's ladder, verbatim from the parse error in the issue. */ +const OLD_CLI = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); +const NEW_CLI = new Set([...OLD_CLI, "max", "ultra"]); + +describe("#4207 catalog effort compatibility", () => { + test("reports the levels an older runtime would reject, without changing the catalog", () => { + const models = JSON.parse(catalogBody(["low", "high", "max"])).models; + const before = JSON.stringify(models); + + const result = catalogEffortCompatibility(models, OLD_CLI); + + expect(result.compatible).toBe(false); + expect(result.unsupportedEfforts).toEqual(["max"]); + expect(result.affectedModels).toEqual(["gpt-5.6-sol"]); + // The clamp beside it mutates; this one must not, or the client would silently disagree + // with hub truth. + expect(JSON.stringify(models)).toBe(before); + }); + + test("a default level the runtime does not know is an incompatibility too", () => { + // The CLI parses default_reasoning_level with the same enum, so a ladder that survives + // the filter can still fail on the default alone. + const models = JSON.parse(catalogBody(["low", "high"], "gpt-5.6-sol", "ultra")).models; + const result = catalogEffortCompatibility(models, OLD_CLI); + expect(result.compatible).toBe(false); + expect(result.unsupportedEfforts).toEqual(["ultra"]); + }); + + test("a catalog the runtime fully supports is compatible", () => { + const models = JSON.parse(catalogBody(["low", "high", "max"])).models; + expect(catalogEffortCompatibility(models, NEW_CLI)).toEqual({ + compatible: true, + unsupportedEfforts: [], + affectedModels: [], + }); + }); + + test("an unobservable runtime ladder is not evidence of incompatibility", () => { + const models = JSON.parse(catalogBody(["low", "max"])).models; + expect(catalogEffortCompatibility(models, null).compatible).toBe(true); + }); +}); + +describe("#4207 client catalog gate", () => { + test("the exact ladder from the report is refused", () => { + const body = catalogBody(["low", "medium", "high", "xhigh", "max"]); + + expect(() => assertClientCatalogCompatible(body, { supportedEfforts: () => OLD_CLI })) + .toThrow(ClientCatalogIncompatibleError); + }); + + test("the refusal names the level, both remedies, and what was preserved", () => { + let thrown: unknown; + try { + assertClientCatalogCompatible(catalogBody(["low", "max"]), { supportedEfforts: () => OLD_CLI }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(ClientCatalogIncompatibleError); + const message = (thrown as Error).message; + expect(message).toContain("max"); + // "Incompatible" alone is not actionable: the operator has to know which way out exists. + expect(message).toContain("Upgrade the Codex CLI"); + expect(message).toContain("CODEX_CLI_PATH"); + expect(message).toContain("The previous catalog was kept"); + expect((thrown as ClientCatalogIncompatibleError).unsupportedEfforts).toEqual(["max"]); + }); + + test("a compatible catalog passes the gate", () => { + expect(() => assertClientCatalogCompatible( + catalogBody(["low", "high", "max"]), + { supportedEfforts: () => NEW_CLI }, + )).not.toThrow(); + }); + + test("an unverifiable runtime does not block the connection", () => { + // A client machine may legitimately have no Codex CLI to observe. Refusing then would + // block a working configuration on absent evidence rather than on an incompatibility. + const assessment = assessClientCatalogCompatibility(catalogBody(["max"]), { supportedEfforts: () => null }); + expect(assessment.kind).toBe("unverified"); + expect(() => assertClientCatalogCompatible(catalogBody(["max"]), { supportedEfforts: () => null })) + .not.toThrow(); + }); + + test("an unreadable body is unverified, not blamed on the runtime", () => { + // The hub client already rejects a malformed body with its own cause. Inventing a second + // one here would repeat #4169, where a refusal named a cause the server never reported. + const assessment = assessClientCatalogCompatibility("not json", { supportedEfforts: () => OLD_CLI }); + expect(assessment.kind).toBe("unverified"); + }); + + test("both catalog downloads are gated, and the restore paths are not", () => { + // connect and sync each write the hub's bytes to the same path; a gate on only one of them + // still lets a sync replace a parseable catalog with an unparseable one. + const source = readFileSync(repoPath("src", "client", "connect.ts"), "utf8"); + for (const written of ["catalog.body", "downloaded.body"]) { + const write = source.indexOf(`atomicWriteFile(DEFAULT_CATALOG_PATH, ${written})`); + expect(write).toBeGreaterThan(0); + const gate = source.indexOf(`assertClientCatalogCompatible(${written}`); + expect(gate).toBeGreaterThan(0); + expect(gate).toBeLessThan(write); + } + // Restoring a catalog this machine previously accepted must not be gated on a runtime that + // may since have changed — that would strand the client with no catalog at all. + expect(source).toContain("atomicWriteFile(DEFAULT_CATALOG_PATH, snapshot.body)"); + expect(source.match(/assertClientCatalogCompatible\(/g)).toHaveLength(2); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a9784f67e1..5ecabf4665 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -195,6 +195,7 @@ "cli-transport-honesty.test.ts": "cli", "cli-usage-report.test.ts": "cli", "cli-version-skew.test.ts": "cli", + "client-catalog-compatibility.test.ts": "clients", "client-config-export-new-clients.test.ts": "config", "client-config-export.test.ts": "config", "client-config-new-clients.test.ts": "config", From 8f3e0b890d494c4b11ba7fc9f6637ced04695c4e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:42:41 +0900 Subject: [PATCH 055/126] docs(devlog): record the wp3 client-catalog work-phase --- .../030_wp3_client_catalog.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 devlog/_plan/260911_l4_service_cli/030_wp3_client_catalog.md diff --git a/devlog/_plan/260911_l4_service_cli/030_wp3_client_catalog.md b/devlog/_plan/260911_l4_service_cli/030_wp3_client_catalog.md new file mode 100644 index 0000000000..be2d964772 --- /dev/null +++ b/devlog/_plan/260911_l4_service_cli/030_wp3_client_catalog.md @@ -0,0 +1,66 @@ +# wp3 — #4207: connected catalog reports success while the local Codex CLI rejects it + +Work-phase 3 of the L4 lane, stacked on wp2. No carried PR: this issue had none. + +## The gap + +Subagent Bernoulli mapped the client path. `connectClient` downloads at `connect.ts:542` and +writes the hub's bytes verbatim at `:545-549`; `syncConnectedClient` writes the same way at +`:667`. The only validation in between is `validateRemoteCatalog` +(`hub-client.ts:145`), which checks JSON shape — object, `models` array, unique non-empty +slugs — and nothing about reasoning levels. `src/client` never imports the effort clamp. + +So the connection state proves the hub is reachable and the credential works, and is then +reported as readiness. The reporter's Codex CLI 0.135.0 exited before its first request on +`unknown variant \`max\``, while `ocx connect status --json` said `connected` with the catalog +present. `ocx status` even reported an active effort clamp for that same older runtime: the +local machinery already knew the ladder, and the client path simply never consulted it. + +## Decision + +The packet records it: **fail closed — block local readiness rather than reporting success.** +Not a locally clamped projection, which would make the client silently disagree with hub truth. + +## Shape + +- `catalogEffortCompatibility(models, supported)` in `src/codex/catalog/effort.ts` — pure, no + mutation, reports the rejected efforts and the models carrying them. It sits beside + `clampCatalogModelsToObservedCodexSupport`, which mutates; that is correct for a file this + process owns and wrong for one that must keep matching the hub. +- `src/client/catalog-compatibility.ts` — assesses a downloaded body against the observed local + ladder and throws `ClientCatalogIncompatibleError` when it cannot be consumed. +- Both hub-download writes are gated **before** the write. Refusing before the write is stronger + than writing and restoring: there is no window in which an unparseable catalog exists on disk, + and `writtenCatalogFingerprint` stays null so the existing rollback correctly does nothing. +- The two restore paths (`connect.ts:126`, `:729`) are deliberately **not** gated. Refusing to + restore a catalog this machine already accepted would strand the client with none at all. + +## Decisions I had to make + +**An unobservable ladder does not block.** `codexSupportedReasoningEfforts` returns null when +`codex debug models --bundled` cannot be observed. That is not evidence of incompatibility, and a +client machine may legitimately have no Codex CLI. I read the issue's *"preserve the prior +known-good catalog if compatibility cannot be established"* as the incompatible branch — the +alternative to the compatible-projection branch offered in the same sentence — not as the +inconclusive one. Recorded in the PR body too, because the other reading is defensible. + +**An invented command was caught before it shipped.** The first draft of the refusal recommended +`ocx codex-runtime`, which does not exist. `AGENTS.md` records this exact failure mode — a +documented `ocx request-history` that never existed — so every command in the message was +checked against the CLI registry. It now names `CODEX_CLI_PATH` and `ocx sync`, with `ocx doctor` +for diagnosis, matching `doctor.ts`'s existing advice. + +## Audit + +Bohr reviewed the diff adversarially and returned `SAFE_TO_PUSH`: no strict-tsc failure on the +new `src/` lines (checked by hand, since typecheck was NOT RUN), no import cycle into +`src/client` and no module-load side effect, Lab boundary untouched, all four +`atomicWriteFile(DEFAULT_CATALOG_PATH, …)` sites classified, and the gate proven to precede +`commitClientConnection`. One nit folded: a test title claimed write ordering that only the +source-scan test actually asserts, and was renamed. + +## Not run + +`bun test`, `bun run test`, `bun run test:changed`, `bun run typecheck`, `bun run build:gui` +and `bun install` are NOT RUN by operator instruction. Hosted CI on the exact pushed head is +the only product evidence this round accepts. From 59d2dc48f8a1bd5f5049ebed99734e3239c75df7 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:48:13 +0900 Subject: [PATCH 056/126] docs(remote-hub): make the setup runnable on a fresh config and close the macOS data plane The setup block could not be followed verbatim on the standalone install it tells you to make. `ocx config set runtimeRole hub` does not create the `hub` object, and the CLI refuses to create a missing parent, so the guide's own next line failed with `config parent path not found: hub`. The block now creates `hub` and `remoteGui` first, and a whole-object alternative is offered for a genuinely empty config with the warning that a whole-object set replaces rather than merges. The second gap was the data plane. The guide published only the loopback management ingress through Tailscale Serve, and that ingress never serves `/v1/*`, `/healthz` or `/readyz`, so the deployment paired and then could not answer a request. A new section gives the data listener TLS, including the macOS constraint that Serve proxies only to 127.0.0.1 and therefore cannot reach a listener bound to the node's own tailnet address. It also documents the trap that makes this quiet. Binding the data listener to loopback looks like the obvious fix, but a loopback bind is how opencodex recognizes a local deployment: it stops requiring a data credential and starts requiring a loopback `Host`. A TLS frontend forwards the ts.net name, so `/v1/catalog` answers 403 while `/readyz` still returns 200 and the hub looks healthy. Two smaller corrections in the same file: the troubleshooting list offered `--allow-insecure-http`, which does not exist in `ocx connect` and has no equivalent since pairing began refusing non-loopback HTTP outright; and the Docker section's nested sets now say why they work there, so the two sections no longer read as two different rules. The seven translated copies still carry the dead flag and the old setup block; English first, translations to follow, as the issue asks. Closes #4200 --- .../src/content/docs/guides/remote-hub.md | 95 ++++++++++++++++++- scripts/test-layout/layout.json | 1 + .../docs-remote-hub-claims.test.ts | 88 +++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 tests/ci-workflows/docs-remote-hub-claims.test.ts diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 6a2b2a33bd..60331d9746 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -74,8 +74,14 @@ for management. The values below are examples: ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# A fresh standalone config has no `hub` or `remoteGui` object, and `ocx config set` does not +# create a missing parent: a nested set fails with `config parent path not found: hub`. Setting +# `runtimeRole` does not create it either. Create each object first, then set its fields. +ocx config set hub '{}' +ocx config set remoteGui '{}' +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' @@ -86,6 +92,25 @@ ocx service install ocx service status ``` +On a genuinely empty configuration you can set each object in one call instead: + +```bash +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}' +ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' +``` + +Use that form only when the object does not exist yet. A whole-object set **replaces** the object +rather than merging into it, so running the line above against a config that already had +`hub.managementIngress` silently drops the ingress. When you are adapting an existing +configuration, set one field at a time — the parent is already there, so the nested form works and +touches nothing else. + +Two details that decide whether a line is accepted. The value is parsed as JSON first and falls +back to the raw string, which is why a URL is written as `'"https://…"'`: objects, arrays, booleans +and numbers must be valid JSON. And `hub` and `remoteGui` are strict, so a mistyped key is rejected +at write time as `schema_invalid: hub.` instead of becoming a setting that never takes +effect. `managementPublicOrigin` must be a bare origin with no path, query or fragment. + `ocx service install` copies the token into the existing owner-only `service-api-token` path. The launchd plist and systemd user unit read that protected file when the process starts; neither embeds the literal token. Do not paste the value into `ocx config show`, unit/plist output, screenshots, or @@ -129,6 +154,63 @@ The positive browser test must use a real signed-in Tailscale session; a bare `c identity headers needed for automatic session issuance. Pairing remains the fallback when the HTTPS frontend cannot provide trustworthy Tailscale identity. +### Giving the data listener TLS + +The Serve mapping above publishes the **management** ingress only. That ingress never serves +`/v1/*`, `/healthz` or `/readyz`, so on its own it does not give a remote client a usable data +plane. opencodex also terminates no TLS of its own: the listener is plain HTTP and HTTPS is always +an operator-owned frontend. + +Serve can be that frontend for the data plane too, on a second HTTPS port. On macOS it needs one +extra hop, because Tailscale Serve proxies only to `127.0.0.1` — it cannot target the listener you +bound to the node's own tailnet address, and the App Store build of the macOS client refuses a +remote destination outright. Run a loopback forwarder on the hub and point Serve at that: + +```bash +# Any loopback TCP forwarder works; socat is one. The data listener is bound to the tailnet +# address, so 127.0.0.1:10100 is free for the forwarder to take. +socat TCP-LISTEN:10100,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & + +tailscale serve --bg --https=8443 http://127.0.0.1:10100 +tailscale serve status # expect both mappings: 443 -> 10101, 8443 -> 10100 +``` + +Serve accepts a limited set of HTTPS ports; confirm the mapping was actually created with +`tailscale serve status` rather than assuming the port was allowed. + +Give the forwarder the same lifetime as the hub. A backgrounded shell job dies on reboot while the +service comes back up, which leaves a hub that is running and unreachable over TLS; run it from +launchd or systemd alongside `ocx service install`. + +Then connect with the two origins stated separately. The positional URL is the **data** origin — +it is where `/readyz` and `/v1/catalog` are fetched — and `--management-url` is the dashboard +origin used for pairing and key issuance. They do not have to share a port: + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +When `--management-url` is omitted it is taken from the `/readyz` response, which reports +`hub.managementPublicOrigin`. Setting it explicitly is clearer when the two origins differ. + +**Do not shortcut this by binding the data listener to `127.0.0.1`.** A loopback bind is how +opencodex recognizes a purely local deployment: it stops requiring a data credential, and it starts +requiring the request's `Host` header to be loopback as well. A TLS frontend forwards +`Host: hub-name.tailnet-name.ts.net`, so `/v1/catalog` answers `403 origin_rejected` — while +`/readyz`, which does not run that check, still returns `200`. The deployment looks healthy and +cannot serve a model. Nothing in the request path reads `X-Forwarded-Host`, so the frontend cannot +repair it. Keep the listener on the tailnet address, where credential admission stays on and the +`Host` check does not apply. + +Binding `0.0.0.0` also works and removes the need for a forwarder, since the listener is then +reachable on loopback as well. It publishes the data port on every interface, so prefer it only +where the host has no other network you care about. + +Re-run the acceptance checks against the HTTPS data origin once Serve is up: `/readyz`, an +authenticated `GET /v1/catalog`, and one real routed response. + ### Operator-owned ts.net certificate proxy If you operate your own TLS proxy, obtain a certificate only for the full ts.net FQDN: @@ -269,6 +351,10 @@ docker compose run --rm hub bun run src/cli/index.ts config set remoteGui.allowe docker compose restart hub ``` +These nested sets work because the image seeds a first-run `hub` configuration, so the object +already exists. On a fresh standalone install it does not, and the same lines fail until you create +it — see [Linux systemd or macOS launchd](#linux-systemd-or-macos-launchd) above. + Do not put a token in `ARG`, `ENV`, `COPY`, Compose YAML, image history, or command arguments. Do not mount the Docker socket, the host's home or Codex home, SSH agent, or provider-key files. A management ingress bound to `127.0.0.1:10101` inside the container is reachable only by a TLS/tailnet frontend @@ -328,8 +414,11 @@ For a service rollback, stop the branch service and repair the prior release aga Negotiation fails before token, catalog, journal, or client-state writes. - **Lost or burned pairing code:** create a new short-lived code. Grants are one-use and repeated failures are rate-limited without revealing whether a code exists. -- **Plain HTTP warning:** pairing over non-loopback HTTP requires the explicit - `--allow-insecure-http` opt-in. Admin tokens are never sent over HTTP. +- **Plain HTTP refused:** pairing over non-loopback HTTP is refused outright, and there is no flag + that opts out of it. Put the management origin behind HTTPS, or pair over loopback. Admin tokens + are never sent over HTTP. +- **`403 origin_rejected` from `/v1/catalog` while `/readyz` returns `200`:** the data listener is + bound to loopback behind a TLS frontend. See [Giving the data listener TLS](#giving-the-data-listener-tls). - **Remote session ended:** sign in or pair again. Logout and expiry invalidate only the browser session, not a client data key. - **Outstanding revocation after disconnect:** use the hub dashboard's **Integrations → API Keys** diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 211e1f5c07..ab3c39df6e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -615,6 +615,7 @@ "docs-bun-source-requirement.test.ts": "ci-workflows", "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", + "docs-remote-hub-claims.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", "doctor-oauth.test.ts": "service", "doctor-provider-apikey.test.ts": "service", diff --git a/tests/ci-workflows/docs-remote-hub-claims.test.ts b/tests/ci-workflows/docs-remote-hub-claims.test.ts new file mode 100644 index 0000000000..c2d73f428e --- /dev/null +++ b/tests/ci-workflows/docs-remote-hub-claims.test.ts @@ -0,0 +1,88 @@ +/** + * The remote hub guide has to stay runnable end to end on a FRESH standalone config. + * + * It did not (#4200). The setup block told the reader to run a nested `ocx config set hub.` + * immediately after `ocx config set runtimeRole hub`, but `runtimeRole` does not create the object + * and the CLI refuses to create a missing parent, so the guide's own next line died with + * `config parent path not found: hub`. A guide that cannot be followed verbatim is worse than a + * missing one: the reader assumes they broke something. + * + * The second half is the data plane. The management ingress deliberately serves no `/v1/*`, + * `/healthz` or `/readyz`, so publishing only that ingress through Tailscale Serve leaves a hub + * that pairs and then cannot answer a request. The trap is quiet, because a loopback-bound data + * listener still returns 200 from `/readyz` while answering 403 on `/v1/catalog`. + * + * These assertions are cheap and the guide is edited often, which is the whole reason the first + * defect survived to a public URL. + */ +import { describe, expect, test } from "bun:test"; +import { repoPath } from "../helpers/repo-root"; + +const GUIDE = repoPath("docs-site/src/content/docs/guides/remote-hub.md"); + +describe("remote hub guide", () => { + test("no nested config set runs before its parent object exists", async () => { + const source = await Bun.file(GUIDE).text(); + + // The ordering IS the fix. Asserting only that the initializer appears somewhere would pass on + // a guide that still sets the field first and mentions `{}` afterwards. + for (const parent of ["hub", "remoteGui"] as const) { + const initializer = source.indexOf(`ocx config set ${parent} '{}'`); + const nested = source.indexOf(`ocx config set ${parent}.`); + expect(initializer, `the guide no longer initializes an empty ${parent} object`).toBeGreaterThanOrEqual(0); + expect(nested, `the guide no longer sets any ${parent} field`).toBeGreaterThanOrEqual(0); + expect( + initializer, + `the guide sets a ${parent}. before creating ${parent}, which fails on a fresh config`, + ).toBeLessThan(nested); + } + + // Name the error, so a reader who hit it recognizes their own terminal output. + expect(source).toContain("config parent path not found: hub"); + }); + + test("the whole-object form carries its replace-not-merge warning", async () => { + // `setPath` assigns the leaf. Recommending the one-call form without this warning would tell + // an operator adapting an existing config to silently drop their management ingress. + const source = await Bun.file(GUIDE).text(); + expect(source).toContain("replaces** the object"); + }); + + test("the guide says opencodex terminates no TLS itself", async () => { + // There is no tls/cert/key field in OcxConfig. A reader who assumes otherwise looks for a + // setting that does not exist instead of standing up a frontend. + const source = await Bun.file(GUIDE).text(); + expect(source).toContain("terminates no TLS of its own"); + }); + + test("ocx connect is shown with a data origin and a separate management origin", async () => { + // The positional URL is where /readyz and /v1/catalog are fetched; --management-url is where + // pairing and key issuance go. They need not share a port, and the macOS recipe relies on that. + const source = await Bun.file(GUIDE).text(); + expect(source).toContain("ocx connect https://hub-name.tailnet-name.ts.net:8443"); + expect(source).toContain("--management-url https://hub-name.tailnet-name.ts.net"); + }); + + test("the macOS Serve constraint and the loopback-bind trap are both documented", async () => { + const source = await Bun.file(GUIDE).text(); + // Serve cannot reach a listener bound to the node's own tailnet address. + expect(source).toContain("Tailscale Serve proxies only to"); + // And the obvious workaround -- bind the listener to loopback -- breaks the catalog quietly. + expect(source).toContain("403 origin_rejected"); + expect(source).toContain("X-Forwarded-Host"); + }); + + test("the Docker section does not contradict the standalone parent-object rule", async () => { + // Compose seeds a hub object, so its nested sets work. Without saying so, the two sections + // read as two different rules and the reader cannot tell which applies to them. + const source = await Bun.file(GUIDE).text(); + expect(source).toContain("because the image seeds a first-run"); + }); + + test("the retired --allow-insecure-http flag is not offered", async () => { + // It is absent from CONNECT_USAGE, pairing refuses non-loopback HTTP outright, and + // remoteGui.allowInsecureHttp is a retired no-op. Offering it sends an operator to an error. + const source = await Bun.file(GUIDE).text(); + expect(source).not.toContain("--allow-insecure-http"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 02a8b205d5..8e025c4cef 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -450,6 +450,7 @@ "docs-bun-source-requirement.test.ts": "ci-workflows", "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", + "docs-remote-hub-claims.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", "doctor-oauth.test.ts": "service", "doctor-provider-apikey.test.ts": "service", From cdf52e05d8bd70d389949039caab4acee4368ed4 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:48:55 +0900 Subject: [PATCH 057/126] docs(devlog): record the #4200 delivery and the loopback-bind finding --- .../_plan/260911_l7_docs/050_4200_delivery.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 devlog/_plan/260911_l7_docs/050_4200_delivery.md diff --git a/devlog/_plan/260911_l7_docs/050_4200_delivery.md b/devlog/_plan/260911_l7_docs/050_4200_delivery.md new file mode 100644 index 0000000000..0afe228e79 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/050_4200_delivery.md @@ -0,0 +1,75 @@ +# #4200 — delivery record + +Commit `59d2dc48f8` on the stacked branch `codex/260911-l7-remote-hub`, based on the #4215 head. +Closes #4200. + +## What shipped + +`docs-site/src/content/docs/guides/remote-hub.md`: + +- The setup block creates `hub` and `remoteGui` before setting any field, and names the exact error + a reader would otherwise hit. A whole-object alternative is offered for a genuinely empty config, + with the warning that a whole-object set replaces rather than merges, plus the two details that + decide whether a line is accepted at all: the value is parsed as JSON first, and both objects are + strict so a mistyped key is rejected at write time. +- A new **Giving the data listener TLS** section states that opencodex terminates no TLS itself, + gives the macOS recipe, shows `ocx connect` with a data origin and a separate `--management-url`, + and documents the loopback-bind trap. +- The troubleshooting list loses `--allow-insecure-http`, which does not exist, and gains the + `403 origin_rejected` symptom pointing at the new section. +- The Docker section says why its nested sets work there, so the guide states one rule. + +`tests/ci-workflows/docs-remote-hub-claims.test.ts` guards all of it, registered in both layout maps. + +## The finding that shaped the recipe + +The issue asks for "a supported macOS Tailscale-extension data transport example". The obvious one +— bind the data listener to loopback so Serve can reach it — is wrong, and wrong in a way that +passes a health check. + +`isApiAuthRequired` is `!isLoopbackHostname(config.hostname)`, keyed on the **configured bind** +address rather than the socket or the `Host` header (`src/server/auth-cors.ts:288`-`290`). A loopback +bind therefore takes the first arm of `isAllowedRequestOrigin` (`auth-cors.ts:90`-`94`), which stops +requiring a data credential and starts requiring the request's `Host` to be loopback too. A TLS +frontend forwards `Host: hub-name.tailnet-name.ts.net`, so `/v1/catalog` returns +`403 origin_rejected` (`src/server/index.ts:1303`) — while `/readyz`, which never runs that check +(`index.ts:1222`-`1242`), still returns `200`. Nothing reads `X-Forwarded-Host`, so the frontend +cannot repair it. + +So the guide keeps the tailnet bind, where credential admission stays on and the `Host` check does +not apply, and puts a loopback forwarder in front for Serve to target. That is also what the issue +reporter deployed successfully. + +| `hostname` | Serve can reach it? | `/v1/catalog` | +|---|---|---| +| `127.0.0.1` | yes | 403 `origin_rejected` — the trap | +| `0.0.0.0` | yes | works; publishes on every interface | +| tailnet IP | no, needs a forwarder | works | + +## Decisions this lane made + +- **Documentation only.** The issue's review explicitly leaves auto-creating a missing parent out + of scope, so `src/cli/config-command.ts` is untouched and the guide documents the CLI as it + behaves. +- **Both config forms are shown**, because they are good at different things: whole-object for a + fresh config, `'{}'` plus nested sets when adapting an existing one without dropping siblings. +- **The dead `--allow-insecure-http` is fixed in the same PR.** It is one line in an owned file and + the same class of defect the issue reports — a published command that cannot run. Leaving a + known-false command beside the one being corrected would be indefensible. +- **Serve's HTTPS port set is not asserted.** The guide uses 8443 and tells the reader to confirm + with `tailscale serve status`, rather than publishing a port list this lane did not verify. +- **Translations are a follow-up.** The seven locale copies still carry the dead flag and the old + setup block. They are outside this lane's owned paths, and the issue asks for English first. + +## Verification + +Local product suite, typecheck and build NOT RUN by operator instruction. Hosted CI on the exact +pushed head is the proof. + +Two read-only reviewers stood in for the local run. One verified every assertion in the new test by +reading, including the ordering assertion that is the actual fix — it confirmed the initializer +precedes the first nested set, and that neither the Docker Compose lines nor the Rollback section +steal the `indexOf` the test depends on. The other checked the recipe against the admission code, +quoting the predicate, and confirmed the anchors, the scope, and both layout registrations. Its one +residual finding — a backgrounded forwarder does not survive a reboot while the service does — is +folded into the guide. From 9cdcb43fea2d58757cf5a2d08e011700da842da8 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 09:09:23 +0900 Subject: [PATCH 058/126] fix(qoder): stop forwarding the region between a suppressed block and a refusal The guard keeps the text that precedes a scaffolding marker, on the reasoning that it is the model's own answer. That holds for the first marker in a turn. It does not hold once a reminder block has already been suppressed on that channel: the text between that block and the next unrepairable marker is the region the vendor was narrating in, and in the reported leak it carries the MCP server list. Two inputs published it and then refused the turn, which is the worst of both: a\n## Connected MCP servers\n- deploy-keys Status.\n- deploy-keys\n... The first forwarded "## Connected MCP servers\n- deploy-keys", the second "\n- deploy-keys\n", before emitting vendor_scaffold_detected. The prefix is now kept only while the channel has not yet suppressed a block, so an answer that simply precedes a leak still survives and a post-block region does not. Both inputs have a case. Refs #4190 --- src/adapters/qoder/scaffold-guard.ts | 12 +++++++++-- tests/providers/qoder-scaffold-guard.test.ts | 21 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts index 88c93d3e6d..f7a485f0d2 100644 --- a/src/adapters/qoder/scaffold-guard.ts +++ b/src/adapters/qoder/scaffold-guard.ts @@ -84,6 +84,8 @@ export class QoderScaffoldFilter { private suppressedTail = ""; private suppressedChars = 0; private failed = false; + /** True once a reminder block has been suppressed on this channel. */ + private suppressedBlock = false; push(chunk: string): ScaffoldFilterResult { if (this.failed || !chunk) return { text: "", fail: null }; @@ -131,9 +133,15 @@ export class QoderScaffoldFilter { return { text: cleared, fail: null }; } - // Text produced before the scaffolding is the model's own answer; it is kept either way. - cleared += buffer.slice(0, earliest); + // Text produced before the scaffolding is the model's own answer, and it is kept — but + // only while this channel has not already suppressed a block. Once it has, the text + // between that block and an unrepairable marker is not an answer that happens to + // precede a leak; it is the region the vendor was narrating in, and in the reported + // case it carries the MCP server list. Forwarding it on the way to a refusal would + // publish exactly what the refusal exists to contain. + if (found === REMINDER_OPEN || !this.suppressedBlock) cleared += buffer.slice(0, earliest); if (found !== REMINDER_OPEN) return this.fail(cleared, `vendor tool-call markup (${found})`); + this.suppressedBlock = true; this.mode = "suppress"; this.suppressedTail = ""; this.suppressedChars = 0; diff --git a/tests/providers/qoder-scaffold-guard.test.ts b/tests/providers/qoder-scaffold-guard.test.ts index 21ac6789d5..014387bdf6 100644 --- a/tests/providers/qoder-scaffold-guard.test.ts +++ b/tests/providers/qoder-scaffold-guard.test.ts @@ -67,6 +67,27 @@ describe("QoderScaffoldFilter", () => { expect(new QoderScaffoldFilter().push("tail").fail).toContain(""); }); + test("does not forward the region between a suppressed block and a refusal", () => { + // The text before the FIRST marker is the model's answer and is kept. The text after a + // block this filter already swallowed is the vendor's own narration, and in the reported + // leak that region is the MCP server list itself. + const filter = new QoderScaffoldFilter(); + const result = filter.push( + `a\n## Connected MCP servers\n- deploy-keys`, + ); + expect(result.text).toBe(""); + expect(result.text).not.toContain("deploy-keys"); + expect(result.fail).toContain(""); + }); + + test("does not forward vendor narration that sits between a reminder and tool markup", () => { + const filter = new QoderScaffoldFilter(); + const result = filter.push(`Status.${REMINDER}\n- deploy-keys\n${TOOL_MARKUP}`); + expect(result.text).toBe("Status."); + expect(result.text).not.toContain("deploy-keys"); + expect(result.fail).toContain(" { const filter = new QoderScaffoldFilter(); expect(filter.push("ok listing servers").fail).toBeNull(); From 99028b618a7872b2d31c53a8ee2d39dc25e3dd9e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 09:18:18 +0900 Subject: [PATCH 059/126] fix(qoder): close three more scaffolding leaks in the routed-output guard An adversarial read of the filter found three inputs that still put MCP server names or a vendor command into the text channel. A nested reminder ended at the inner closer, so the outer block's remaining body was forwarded as the model's answer and the turn SUCCEEDED. Suppression now counts depth and only the closer that unwinds the last level ends it; the retained tail widened to the longest marker so a split opener is still seen. A closer with no opener kept the text ahead of it. That text is the lost block's body, not an answer that happened to precede a leak, so a closing marker no longer contributes a prefix. "", so the command inside such a block shipped before the refusal. "" is now its own marker, which leaves "" so a truncated or self-closed tag still suppresses, which made "" in an ordinary answer open a block and refuse the turn. It now needs a token boundary. Refs #4190 --- src/adapters/qoder/scaffold-guard.ts | 62 ++++++++++++++++++-- tests/providers/qoder-scaffold-guard.test.ts | 56 ++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts index f7a485f0d2..8a1b6df620 100644 --- a/src/adapters/qoder/scaffold-guard.ts +++ b/src/adapters/qoder/scaffold-guard.ts @@ -36,13 +36,29 @@ const REMINDER_CLOSE = ""; * text is not possible. A stray `` with no opener is in the same class: * the block it belonged to was already partly forwarded, or never existed. */ -const UNREPAIRABLE_MARKERS = ["", REMINDER_CLOSE] as const; +const UNREPAIRABLE_MARKERS = ["", "", REMINDER_CLOSE] as const; + +/** Markers that end a block rather than start one; their prefix is never an answer. */ +const CLOSING_MARKERS = new Set(["", REMINDER_CLOSE]); /** Every marker the scanner must be able to recognize mid-split. */ const ALL_MARKERS = [REMINDER_OPEN, ...UNREPAIRABLE_MARKERS] as const; const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length)); +/** + * True when `` so a truncated or self-closed tag still suppresses, + * which means `` in an ordinary answer would otherwise open a block and + * refuse the turn. A stem running to the end of the buffer still counts: more text may be + * arriving, and reading it as prose is the one reading that could release the block body. + */ +function reminderOpensHere(lowered: string, at: number): boolean { + const after = lowered[at + REMINDER_OPEN.length]; + return after === undefined || /[\s/>]/.test(after); +} + /** * Ceiling on a suppressed block before it is treated as unterminated. * @@ -86,6 +102,8 @@ export class QoderScaffoldFilter { private failed = false; /** True once a reminder block has been suppressed on this channel. */ private suppressedBlock = false; + /** Open reminder blocks; only the closer that unwinds the last one ends suppression. */ + private suppressDepth = 0; push(chunk: string): ScaffoldFilterResult { if (this.failed || !chunk) return { text: "", fail: null }; @@ -96,14 +114,40 @@ export class QoderScaffoldFilter { for (;;) { if (this.mode === "suppress") { const scan = this.suppressedTail + buffer; - const close = scan.toLowerCase().indexOf(REMINDER_CLOSE); + const scanned = scan.toLowerCase(); + // Unwind nesting rather than ending at the first closer. A reminder containing another + // reminder would otherwise hand the outer block's remaining body — the MCP server list + // in the reported leak — to the client as the model's answer, with a successful + // terminal and nothing to signal that anything had gone wrong. + let cursor = 0; + let close = -1; + for (;;) { + const nextClose = scanned.indexOf(REMINDER_CLOSE, cursor); + if (nextClose < 0) break; + let nextOpen = scanned.indexOf(REMINDER_OPEN, cursor); + while (nextOpen >= 0 && !reminderOpensHere(scanned, nextOpen)) { + nextOpen = scanned.indexOf(REMINDER_OPEN, nextOpen + 1); + } + if (nextOpen >= 0 && nextOpen < nextClose) { + this.suppressDepth += 1; + cursor = nextOpen + REMINDER_OPEN.length; + continue; + } + this.suppressDepth -= 1; + cursor = nextClose + REMINDER_CLOSE.length; + if (this.suppressDepth === 0) { + close = nextClose; + break; + } + } if (close < 0) { this.suppressedChars += buffer.length; if (this.suppressedChars > MAX_SUPPRESSED_CHARS) { return this.fail(cleared, `an unterminated ${REMINDER_OPEN}> block`); } // The block is discarded as it arrives; only enough tail to spot a split closer is kept. - this.suppressedTail = scan.slice(Math.max(0, scan.length - (REMINDER_CLOSE.length - 1))); + // The tail must cover a split opener too, now that nesting is counted. + this.suppressedTail = scan.slice(Math.max(0, scan.length - (MAX_MARKER_LENGTH - 1))); return { text: cleared, fail: null }; } buffer = scan.slice(close + REMINDER_CLOSE.length); @@ -117,7 +161,10 @@ export class QoderScaffoldFilter { let found = ""; const lowered = buffer.toLowerCase(); for (const marker of ALL_MARKERS) { - const at = lowered.indexOf(marker); + let at = lowered.indexOf(marker); + while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(lowered, at)) { + at = lowered.indexOf(marker, at + 1); + } if (at < 0) continue; // A closer sitting exactly where an opener starts cannot happen, so ties are impossible. if (earliest < 0 || at < earliest) { @@ -139,10 +186,15 @@ export class QoderScaffoldFilter { // precede a leak; it is the region the vendor was narrating in, and in the reported // case it carries the MCP server list. Forwarding it on the way to a refusal would // publish exactly what the refusal exists to contain. - if (found === REMINDER_OPEN || !this.suppressedBlock) cleared += buffer.slice(0, earliest); + // A closer with no opener never keeps its prefix either: the block it belonged to was + // already partly forwarded or never existed, so the text ahead of it is that body. + if (!CLOSING_MARKERS.has(found) && (found === REMINDER_OPEN || !this.suppressedBlock)) { + cleared += buffer.slice(0, earliest); + } if (found !== REMINDER_OPEN) return this.fail(cleared, `vendor tool-call markup (${found})`); this.suppressedBlock = true; this.mode = "suppress"; + this.suppressDepth = 1; this.suppressedTail = ""; this.suppressedChars = 0; buffer = buffer.slice(earliest + REMINDER_OPEN.length); diff --git a/tests/providers/qoder-scaffold-guard.test.ts b/tests/providers/qoder-scaffold-guard.test.ts index 014387bdf6..65f06d7bc1 100644 --- a/tests/providers/qoder-scaffold-guard.test.ts +++ b/tests/providers/qoder-scaffold-guard.test.ts @@ -88,6 +88,62 @@ describe("QoderScaffoldFilter", () => { expect(result.fail).toContain(" { + // Ending at the first closer handed the outer block's remaining body to the client as + // the model's answer, with a successful terminal and no signal that anything was wrong. + const filter = new QoderScaffoldFilter(); + const result = filter.push( + "outerinner\n## Connected MCP servers\n- deploy-keys", + ); + expect(result.text).toBe(""); + const flushed = filter.flush(); + expect(flushed.text).not.toContain("deploy-keys"); + expect(flushed.fail).toContain("unterminated"); + }); + + test("keeps the answer after a nested reminder that does close", () => { + const filter = new QoderScaffoldFilter(); + const result = filter.push( + "oi- deploy-keys Done.", + ); + expect(result.text).toBe(" Done."); + expect(result.fail).toBeNull(); + }); + + test("counts nesting even when the tags are split across deltas", () => { + const filter = new QoderScaffoldFilter(); + const parts = ["oi- deploy-keys Done."]; + const out = parts.map(part => filter.push(part)); + expect(out.map(result => result.text).join("")).toBe(" Done."); + expect(out.every(result => result.fail === null)).toBe(true); + }); + + test("a closer with no opener forwards nothing ahead of it", () => { + // The prefix of a stray closer is the lost block's body, not an answer that preceded it. + const filter = new QoderScaffoldFilter(); + const result = filter.push("## Connected MCP servers\n- deploy-keys"); + expect(result.text).toBe(""); + expect(result.fail).toContain(""); + expect(new QoderScaffoldFilter().push("cd /srv/private && git status").text).toBe(""); + }); + + test("catches an invoke block that carries no attributes", () => { + // "", so the command shipped ahead of the refusal. + const filter = new QoderScaffoldFilter(); + const result = filter.push("Checking.\n\ncd /srv/private && git status\n"); + expect(result.text).toBe("Checking.\n"); + expect(result.text).not.toContain("git status"); + expect(result.fail).toContain(""); + }); + + test("does not open a block on a word that merely starts with the tag name", () => { + // The opener is matched without its ">", so it needs a token boundary of its own. + const filter = new QoderScaffoldFilter(); + const result = filter.push("the are documented"); + expect(result.text + filter.flush().text).toBe("the are documented"); + expect(result.fail).toBeNull(); + }); + test("fails closed when a reminder is never terminated", () => { const filter = new QoderScaffoldFilter(); expect(filter.push("ok listing servers").fail).toBeNull(); From 7ff8d07dae2dd5a8476cb727773785c13078ef10 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 11 Sep 2026 11:18:51 +0900 Subject: [PATCH 060/126] fix(service): make macOS repair verify, roll back, and stop evicting healthy jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ocx service repair` on darwin IS `installLaunchd`, and it evicted the live job with a domain-explicit `bootout`, re-registered with the domain-IMPLICIT legacy `launchctl load -w`, and accepted exit-0-with-empty-stderr as proof. One repair took a hub fully offline — public proxy, management ingress and loopback listener — with no error, no rollback, and no log line. Recovery needed `launchctl bootstrap gui/$uid `, which the CLI never printed. Meanwhile `diagnoseService` derived "loaded" from `launchctl list | grep