diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00abe5ec24d..ba7a41c7633 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -535,9 +535,10 @@ jobs: # (`is_bun_runtime_crash`). The unsharded macOS control had no equivalent, # so the same crash that Linux absorbs failed the whole promotion here. # - # Keep the signature list in sync with `is_bun_runtime_crash` in - # scripts/ci/run-bun-test-batches.sh. An assertion failure still fails on - # the first attempt — only the crash signature is retried, exactly once. + # The classifier is `is_bun_runtime_crash` from scripts/ci/bun-crash-signatures.sh, which + # this leg, the Windows leg, the macOS control and the Linux batch runner all source. It used + # to be four inline copies kept in sync by a test; one definition cannot drift. An assertion + # failure still fails on the first attempt — only a crash is retried, exactly once. - name: Test env: MACOS_TEST_SHARD: ${{ matrix.shard }} @@ -546,6 +547,8 @@ jobs: # errexit so a Bun crash reaches PIPESTATUS and the bounded retry. set +e set -uo pipefail + # One shared classifier for every lane; see scripts/ci/bun-crash-signatures.sh. + source scripts/ci/bun-crash-signatures.sh run_macos_suite() { local suite_log suite_status attempt @@ -559,7 +562,7 @@ jobs: rm -f "$suite_log" return 0 fi - if ! grep -Eqi 'oh no: Bun has crashed|Internal assertion failure|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' "$suite_log"; then + if ! is_bun_runtime_crash "$suite_status" "$suite_log"; then echo "::error::macOS suite failed on attempt ${attempt} (exit ${suite_status}); assertion failures are not retried." rm -f "$suite_log" return "$suite_status" @@ -685,15 +688,17 @@ jobs: # (`is_bun_runtime_crash`). The unsharded macOS control had no equivalent, # so the same crash that Linux absorbs failed the whole promotion here. # - # Keep the signature list in sync with `is_bun_runtime_crash` in - # scripts/ci/run-bun-test-batches.sh. An assertion failure still fails on - # the first attempt — only the crash signature is retried, exactly once. + # The classifier is `is_bun_runtime_crash` from scripts/ci/bun-crash-signatures.sh, shared + # with every other lane. An assertion failure still fails on the first attempt — only a + # crash is retried, exactly once. - name: Test run: | # GitHub Actions starts bash `run:` blocks with `-e`. Disable # errexit so a Bun crash reaches PIPESTATUS and the bounded retry. set +e set -uo pipefail + # One shared classifier for every lane; see scripts/ci/bun-crash-signatures.sh. + source scripts/ci/bun-crash-signatures.sh suite_log="$(mktemp -t ocx-macos-suite.XXXXXX)" for attempt in 1 2; do # --timeout: Bun's default 5s per-test ceiling is the recurring flake @@ -708,7 +713,7 @@ jobs: if [ "$suite_status" -eq 0 ]; then exit 0 fi - if ! grep -Eqi 'oh no: Bun has crashed|Internal assertion failure|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' "$suite_log"; then + if ! is_bun_runtime_crash "$suite_status" "$suite_log"; then echo "::error::macOS suite failed on attempt ${attempt} (exit ${suite_status}); assertion failures are not retried." exit "$suite_status" fi @@ -834,6 +839,8 @@ jobs: run: | set +e set -uo pipefail + # One shared classifier for every lane; see scripts/ci/bun-crash-signatures.sh. + source scripts/ci/bun-crash-signatures.sh suite_log="$(mktemp -t ocx-windows-suite.XXXXXX)" for attempt in 1 2; do bun test --isolate --timeout 60000 tests --shard=${{ matrix.shard }}/6 2>&1 | tee "$suite_log" @@ -841,7 +848,7 @@ jobs: if [ "$suite_status" -eq 0 ]; then exit 0 fi - if ! grep -Eqi 'oh no: Bun has crashed|Internal assertion failure|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' "$suite_log"; then + if ! is_bun_runtime_crash "$suite_status" "$suite_log"; then echo "::error::Windows shard ${{ matrix.shard }}/6 failed on attempt ${attempt} (exit ${suite_status}); assertion failures are not retried." exit "$suite_status" fi diff --git a/Dockerfile b/Dockerfile index 8d3e72c6194..5987bfa991d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Keep the runtime aligned with package.json and pin the multi-platform image index. -ARG BUN_IMAGE=oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 +ARG BUN_IMAGE=oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 FROM ${BUN_IMAGE} AS build WORKDIR /home/bun/app diff --git a/assets/pr-screenshots/usage-chart-review.png b/assets/pr-screenshots/usage-chart-review.png new file mode 100644 index 00000000000..3f2582929a6 Binary files /dev/null and b/assets/pr-screenshots/usage-chart-review.png differ diff --git a/bin/ocx.mjs b/bin/ocx.mjs index ef3aa80cd86..7b3a54eaa2c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -522,6 +522,16 @@ function runPackageManagerSelfUpdate(manager) { " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.", ); } + if (decision.reason === "history-deferred") { + // The reported #4718 path is this lane. Nothing was restored, so this is a different + // sentence from the manifest warning above: an operator told "history metadata is + // incomplete" would assume config and catalog already came back. + console.warn( + "opencodex: WARNING — the shared teardown was refused by the Codex history preflight and restored nothing.\n" + + " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" + + " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.", + ); + } } // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a diff --git a/bun.lock b/bun.lock index 6161766917a..c4619c28666 100644 --- a/bun.lock +++ b/bun.lock @@ -8,11 +8,11 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.4.2", + "bun": "1.4.0", "zod": "4.4.3", }, "devDependencies": { - "@types/bun": "1.4.2", + "@types/bun": "1.4.0", "typescript": "7.0.2", }, }, @@ -60,31 +60,31 @@ "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="], - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="], + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="], - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="], + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-09x7wnjMR6M5KGBDBhVl2CpfoCIQOkVDbPX2KfIhpXv4N6grbWE7dfLPw/Ydi9gaUMGhU7UKhoz444Nu6RCycA=="], - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="], + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dRwzti/qJqV1HWplU27iUWUqp+f2DtFSf2yqQKSb+HH2dDOC//Uqd9u/A5h1DMsLszfP5OGP9UwQIKxVwFODaA=="], - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="], + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y5yAtCbHK6JjprXEtkdklDQFPADgs+CkfcliyY5g4JJ8baGHyQSrfpSkX3XVJ2C+aBLsdwNDdW+oczMsAwx6uA=="], - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.2", "", { "os": "android", "cpu": "arm64" }, "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="], + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.0", "", { "os": "android", "cpu": "arm64" }, "sha512-HpPIxJfDNPBPhiBNMyZoo/dOLijARfsx5j72vNuLtaTvl0Hh7HUculxjsOQ2WSyGoCgqXMEr1Qqjab1im9u1RA=="], - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="], + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RUjAAkJ/CdNV++zVxyANWshPc73CECYsfhk0fWAkoJjtywxJ2BwXzI6nopBBDMfs0HS+fhRGn6zGwU8ccxLeJg=="], - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="], + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Du44zebtPXJujvMLmtIxEQ6ykOhYt7L/Q+YIGVm+Yy+Pj/fpOnq60ggwIpKp/pGAFbYHNiTrA3JTjuZ9MTbZIg=="], - "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.2", "", { "os": "android", "cpu": "x64" }, "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="], + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.0", "", { "os": "android", "cpu": "x64" }, "sha512-u++KyLlfMn36yWz+AgJs+fZtS46UFDNpSSZhrcitkytONtNwq0X6Q9BDVEFXxYl/+Eec0xme1rb6MgW+U35WeA=="], - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1Dv+ISL8YKEKM9jAHzNifOcRUoziy6UMxh+yVXjUCP6QnbRhENDHLaIWWkQZJyBLTn0I3xozflorAlHiGzGqA=="], - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FBAYaQpJBP0asgqzL6NFUfjdQqsV+kvTpJ/eWxPKj+RcDgIfPSuE8kvQuPYu5pa8u8JTujYMjmuyvHxVuQsInA=="], - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="], - "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], @@ -136,9 +136,9 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="], + "bun": ["bun@1.4.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="], - "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], diff --git a/devlog/_plan/260915_2560_release_train/040_release_decision.md b/devlog/_plan/260915_2560_release_train/040_release_decision.md index 971af86c6c8..6261d7d1e52 100644 --- a/devlog/_plan/260915_2560_release_train/040_release_decision.md +++ b/devlog/_plan/260915_2560_release_train/040_release_decision.md @@ -44,3 +44,29 @@ What the decision rests on, and what it does not: Recorded as each step completes. - #4690 head `0026b14e83`, the post-fix candidate. + +## What actually happened + +- Candidate: `386303af1c` on `dev` — the squash of #4690, which carried the two regression fixes. + Its pre-merge head `26b3ff244434846149b560e28f7441afae529564` passed Cross-platform CI as run + `34945255301`. +- `dev` moved to 2.57.0 through #4686 before any promotion, so `assert-ahead` could pass. +- `main`: #4694 merged as `e4a8539b957b7ae7cd278666f0364eb0f82d4ac3`, carrying 2.56.0. Its push + runs at that exact SHA: Cross-platform CI `34947608073` success, Service lifecycle `34947608122` + success. #4687, cut from the pre-fix `2702911708`, was closed as superseded. +- `preview`: #4698 merged as `b552b1db59`. The head was an `ours`-strategy merge, so its tree is + byte-identical to the candidate and to what `main` received; the merge exists to record the old + preview tip as a parent, which is the shape every earlier promotion onto that branch used. +- Release: `release.yml` run `34951392978`, dispatched from `main` with + `expected-sha=e4a8539b95…`, `version=2.56.0`, `tag=latest`, `dry-run=false`. Both jobs succeeded. + The publish step reported `+ @bitkyc08/opencodex@2.56.0` with a provenance statement written to + the sigstore transparency log, and tag `v2.56.0` plus the GitHub release exist. +- Registry metadata still read 2.55.0 immediately afterwards. The workflow says so itself and + instructs against republishing; a lagging read is not a failed publish. + +## What shipped that the audit did not clear + +Nothing. The two regressions it found were fixed before promotion, and the fix itself went through +three review rounds: the first only released in the `catch`, the second confirmed before a rebuild +that can fail without sending, and only the third confirms at the two points that reach the wire. +The accepted risks are listed in `020_regression_audit.md` and are unchanged by this release. diff --git a/devlog/_plan/260916_release_2570_stabilization/031_openai_tiers_split.md b/devlog/_plan/260916_release_2570_stabilization/031_openai_tiers_split.md new file mode 100644 index 00000000000..5b6a3bd54aa --- /dev/null +++ b/devlog/_plan/260916_release_2570_stabilization/031_openai_tiers_split.md @@ -0,0 +1,28 @@ +# Planned split: structure/providers/openai-tiers.md + +## Why there is a grace entry + +`openai-tiers.md` sat at 597 lines on `dev` against a 600-line budget, so it had +room for three lines. Any real contract addition fails the gate, which is what +happened when the account-selection work recorded the uploaded-file retention +invariant and the flagship roster polarity. The doc is now 638 lines and carries +a `grace.oversizeDocs` entry, which `structure/AGENTS.md` reserves for a split +that is already planned. This is that plan. + +## The topic boundary + +The file has held two subjects for a while. One is account identity and wire +shape: Pool and Direct modes, API-key separation, the ChatGPT wire identity, and +the entitlement rosters. The other is selection and quota behaviour: eligibility +guards, priority tiers, cache affinity, reset-first ordering, observed capacity, +and now uploaded-file retention. The split runs on that line, leaving +`providers/openai-tiers.md` with identity and wire shape and moving selection and +quota behaviour into a sibling doc with its own manifest entry. + +## Why it is not done in this release + +Splitting an invariant doc renumbers nothing but does move every anchor other +documents link to, and `structure:check` resolves those references. Doing that +while five behaviour changes are landing would mix a documentation refactor into +the release candidate for no user benefit. The grace entry states the debt +honestly and the gate drops it again once the doc is back under budget. diff --git a/devlog/_plan/260916_release_2570_stabilization/041_preview_fence_test_is_structural.md b/devlog/_plan/260916_release_2570_stabilization/041_preview_fence_test_is_structural.md new file mode 100644 index 00000000000..27f84becf0f --- /dev/null +++ b/devlog/_plan/260916_release_2570_stabilization/041_preview_fence_test_is_structural.md @@ -0,0 +1,46 @@ +# Follow-up: the preview read-fence test asserts shape, not behaviour + +Raised by the third regression audit on the 2.57.0 candidate, deferred past the +release on purpose. + +## What the test does today + +`tests/responses/responses-preview-main-read-fence.test.ts` reads +`src/server/responses/request-prepare.ts` and `src/codex/auth-context.ts` as +text and asserts with regexes that both native-main read fences carry the +request-owned ownership term, that both preview sites validate ownership the way +`resolveCodexAuthContext` does, that no main exclusion is guarded by drain state +alone, and that `nativeMainSelectionOnly` stays derived from the drain. + +It was written that way deliberately, for the reason recorded in its own header: +driving the divergence end to end needs a `thread_spawn` whose caller bearer is +forwardable, an account-gated candidate model, and a denial cache whose only +entry is main. The sibling contract in +`tests/routing/subagent-fallback-preview-sites.test.ts` made the same call for +the same subsystem. + +## Why that is not sufficient + +A structural assertion catches the regression that has actually recurred twice -- +a fence reconstructed inline from drain state, losing the ownership half -- and +nothing else. It cannot see a fence that is present but wired to the wrong +headers, an ownership term computed against a stale route, or a consumer that +stops reading `nativeMainReadsForbidden`. Any of those is a semantic routing +regression that would keep this file green, which means the file reports more +confidence than it holds. + +## What the replacement needs + +A behavioural case that drives `prepareResponsesRequest` with a forwardable +caller bearer on a `thread_spawn` and observes that the preview performs no +credential-validating read of the physical main token and scores main the same +way final authentication does. The expensive part is the fixture, not the +assertion: an account-gated model, a populated denial cache, and an injected +entitlement resolver that records whether main was consulted. The existing pool +harness in `tests/routing/subagent-fallback-handle-responses.test.ts` already +carries most of it, but that file is at its size cap, so the work is a new file +in `tests/routing/` plus its two layout registrations. + +Keep the structural file when the behavioural one lands. They fail on different +things, and the cheap one is what catches the inline-reconstruction regression +before review. diff --git a/devlog/_plan/260917_2570_release_train/000_roadmap.md b/devlog/_plan/260917_2570_release_train/000_roadmap.md new file mode 100644 index 00000000000..72e60db2b20 --- /dev/null +++ b/devlog/_plan/260917_2570_release_train/000_roadmap.md @@ -0,0 +1,77 @@ +# 2.57.0 release train — roadmap + +Status: open. Opened 2026-09-17. + +## Where the repository actually is + +`dev` carries 184 commits since `v2.56.0`, and `package.json` on `dev` already reads 2.57.0 — the +pre-move the 2.56.0 train performed as its own step 2. The version line is therefore ready for a +2.57.0 release, and will need a further move before that release can publish. + +Two things are not ready: + +1. **`dev` is red at its tip.** Cross-platform CI run `35118018849` at `2b19983bfd` failed on the + `windows 1/6` shard with a single failing test, and the four dev commits before it + (`d2808c0619`, `d210c46dab`, `89bdf5fa4a`, `dc9d1fabc8`) each failed a run as well. The last + recorded success on `dev` is `35091966777` at `2203277ad4`. A release cannot be cut from a tree + whose tip has no green run, so establishing whether these are flakes or one regression is the + first work phase, not a side quest. +2. **The queue was never triaged.** 60 pull requests and roughly 60 issues are open. Some issues + are already fixed by unreleased commits on `dev`, some pull requests are superseded by work + that landed around them, and a handful are ready to land now. Publishing without that pass + ships a release whose notes cannot be written honestly and leaves users reading open issues + that the release already fixed. + +## Constraint that shapes the whole unit + +No local full suite, typecheck, build or install, anywhere, by anyone — including delegated +agents. Hosted CI at an exact head SHA is the only accepted evidence that a tree passes. Source +reading and hosted logs are the local instruments. Every claim in these documents names either a +CI run at a SHA, a job id, or a file path with line numbers. + +## Work phases + +| Phase | Doc | Outcome | +| --- | --- | --- | +| wp1 | this file | Roadmap locked. Implementation starts in wp2. | +| wp2 | `010_dev_green.md` | `dev` has a green Cross-platform CI run at its exact tip, with every failure on the way either fixed or proven to be a flake. | +| wp3 | `020_pr_triage.md` | Every open pull request carries a recorded verdict; the ones that land do so with CI green at their exact head. | +| wp4 | `030_issue_triage.md` | Every open issue carries a recorded verdict; issues already fixed by unreleased `dev` commits are closed against the commit that fixed them. | +| wp5 | `040_release.md` | 2.57.0 on `main` and `preview`, published, verified from the workflow's own conclusion. | + +## Release order, restated because it is easy to get backwards + +`MAINTAINERS.md` lines 84-91 and three gates in `.github/workflows/release.yml` force this order: + +1. Freeze a candidate SHA on `dev` that has a green Cross-platform CI run. +2. Move `dev`'s version line **first** — dispatch `dev-version-bump.yml` with the intended version + and merge the pull request it opens. `release.yml` ends with `assert-ahead + ` and refuses to publish while `dev` still reads the version being released. + Doing this after publication is what left `dev` and every open pull request carrying a failure + contributors could not fix from their own diff, ten times. +3. Promote the frozen candidate to `main`, cut from the candidate rather than from the post-bump + `dev` tip. The promotion's `enforce-target` check fails with "wrong base (main)"; that gate is + for feature pull requests and every promotion carries the same red mark. +4. Prove the release SHA: Cross-platform CI success for the promotion commit, and Service + lifecycle success as well, which is always required here because `package.json` always changes. +5. Dispatch `release.yml` with the version, `tag: latest`, `dry-run: false`, and `expected-sha` + equal to the `main` release commit. The branch must not move between step 4 and here. +6. Promote to `preview` so the prerelease train does not restate a shipped stable. +7. Verify the publish from the workflow's own conclusion. Registry lag is not permission to + publish again. + +## Completion criteria + +1. `dev` has a Cross-platform CI success at the exact SHA chosen as the release candidate, and + every failing run between `2203277ad4` and that candidate is accounted for in + `010_dev_green.md` as either fixed (naming the fix commit) or a flake (naming the test and why + it is timing-sensitive). +2. Every open pull request has a verdict of LAND, NEEDS-WORK, HOLD or CLOSE recorded in + `020_pr_triage.md`, and each LAND that was merged names its head SHA and its green run id. +3. Every open issue has a verdict recorded in `030_issue_triage.md`. Issues closed as already + fixed name the `dev` commit that fixed them, and the closing comment says the fix ships in + 2.57.0. +4. 2.57.0 reaches `main` and `preview`, each with hosted CI success at its exact promotion head, + and `release.yml` reports a successful publish dispatched with `expected-sha` equal to the + `main` release commit. +5. No local full suite, typecheck, build or install was run anywhere in this unit. diff --git a/devlog/_plan/260917_2570_release_train/010_dev_green.md b/devlog/_plan/260917_2570_release_train/010_dev_green.md new file mode 100644 index 00000000000..012e08260d1 --- /dev/null +++ b/devlog/_plan/260917_2570_release_train/010_dev_green.md @@ -0,0 +1,50 @@ +# wp2 — turn `dev` green + +## The question + +Five consecutive Cross-platform CI runs on `dev` failed between `2203277ad4` (run `35091966777`, +the last recorded success) and the tip `2b19983bfd`. A release cannot be cut from a tree whose tip +has no green run, and a repeated red usually means a regression. The question this phase answers is +whether it is one. + +## It is not. Every failure is a harness or runtime flake. + +| Run / SHA | Job | Failing test | Class | Evidence | +| --- | --- | --- | --- | --- | +| `35118018849` / `2b19983bfd` | windows 1/6 (`104868879572`) | codex app-server restart routes ride the management gate | filesystem teardown | No auth assertion failed. The failure is an `EPERM` in teardown at `tests/server/server-management-auth.test.ts:223`, after the 15-second removal retry in `scripts/test-temp.ts:195`. | +| `35106190898` / `d2808c0619` | test 1/4 (`104827965367`) | MiniMax CLI wrapper returns 502 when the proxy address is unavailable | port reuse | Expected 502, got 404 at `tests/providers/minimax-clients.test.ts:455`. The fixture releases `deadPort` before opening another port-0 listener (lines 441-448); when the port is reused the bridge calls itself and `src/cli/minimax.ts:158` answers 404. | +| `35098735960` / `d210c46dab` | windows 6/6 (`104804049418`) | client commit guard (deny) | first-touch timeout | The child hit the fixed 30-second deadline at `tests/codex-integration/client-injection-guard.test.ts:133` while sibling modes passed in 1.3-17s. The same file already warms that cost outside the assertion budget at line 200. | +| `35098735960` / `d210c46dab` | windows 5/6 (`104804049465`) | none — Bun crashed entering the file | runtime | Bun 1.4.2 segfaulted at `0x10` before naming a test in `tests/codex-integration/codex-prompt-layers.test.ts`. | +| `35093667426` / `89bdf5fa4a` | windows 4/6 (`104785869855`) | WP13 A-reduced preserves an OFF Codex config/home | timing | `timed out waiting for runtime-port record; child exit=null`, the condition recorded at `tests/codex-integration/codex-composed-acceptance.test.ts:302`. | +| `35093667426` / `89bdf5fa4a` | windows 5/6 (`104785869870`) | none — Bun crashed entering the file | runtime | Same Bun 1.4.2 segfault. | +| `35093667426` / `89bdf5fa4a` | windows 6/6 (`104785869885`) | an exact search 429 never switches | global state | Expected 429, got a local 401 from process-wide `OPENCODEX_HOME` changing between the credential write and read; `tests/server/server-search.test.ts:323` now calls the handler directly. | +| `35087572377` / `dc9d1fabc8` | windows 5/6 (`104765972689`) | none — Bun crashed entering the file | runtime | Same segfault, repeated after the job's one retry. | + +The aggregate `ci` jobs (`104880106091`, `104832004698`, `104813414543`, `104793718177`, +`104774684969`) only propagated these leaves. + +## Two conclusions worth keeping + +The Windows 5/6 rows are a single class: a Bun 1.4.2 runtime crash entering +`codex-prompt-layers.test.ts`. It entered `dev` at `02bc10e8af` and the tip `2b19983bfd` is the +commit that removes it by pinning Bun back to 1.4.0 (`package.json:79`, PR #4821). So the tip is +the fix for the largest failure class, not another instance of it. + +Run `35091966777` is not Windows counterevidence for the earlier reds: its Windows matrix job +`104780185496` was skipped. + +## Candidate + +`2b19983bfd` is the release candidate, proven by a green Cross-platform CI rerun at that exact SHA +(recorded in `040_release.md`). No product-code change was needed to get there. + +## Deferred harness debt + +Two fixes would lower the flake rate and are not release blockers, because neither touches product +code and neither is what made the tip red: + +- `tests/providers/minimax-clients.test.ts` should start the bridge while the reservation still + owns `deadPort` and stop the reservation afterwards, instead of releasing the port first. +- `tests/server/server-management-auth.test.ts` should track the `icacls` child that hardens the + config directory and await it through teardown, so the removal at line 223 is not racing an open + handle. diff --git a/devlog/_plan/260917_2570_release_train/020_pr_triage.md b/devlog/_plan/260917_2570_release_train/020_pr_triage.md new file mode 100644 index 00000000000..688f68c8031 --- /dev/null +++ b/devlog/_plan/260917_2570_release_train/020_pr_triage.md @@ -0,0 +1,66 @@ +# wp3 — pull-request triage + +Every open pull request was inspected at its exact head against `dev` `2b19983bfd`. The headline +result decides the release: **nothing is merge-ready, so 2.57.0 ships the 184 commits already on +`dev` and nothing else.** + +## Why nothing lands + +Two reasons account for almost every verdict. + +The first is missing evidence. A pull request head here typically carries only the four policy +checks — `enforce-target`, `resolve-pr`, `label`, `hygiene` — and no product test suite. GitHub +reports `MERGEABLE / BLOCKED`, which reads like a branch-protection detail but means the required +test check never ran at that head. Merging on that basis would put an untested tree into a release +candidate. + +The second is unresolved review. Several of the ready pull requests carry Codex or CodeRabbit +findings that are correct and open. + +## Ready (non-draft) pull requests + +| PR | Head | Verdict | The one blocking thing | +| --- | --- | --- | --- | +| #4824 | `4c984dbb7c` | NEEDS-WORK | No product CI at head. | +| #4823 | `867c8868f9` | HOLD | A new Opper provider preset is a credential-destination change; the primary-source evidence review required by `MAINTAINERS.md` is not complete. | +| #4816 | `47e8fe61ff` | HOLD | Process-global model-only window cache crosses account and request-mode boundaries (`src/adapters/cursor/discovery.ts:37`). CI green at `35097464862`. | +| #4815 | `407bf3ce56` | HOLD | Textual and structural frames can execute one tool call twice (`src/adapters/cursor/protobuf-events.ts:1273`). Seven open findings. CI green at `35096642245`. | +| #4805 | `91d74200a2` | HOLD | Credential-export change awaiting explicit security review; five open findings. | +| #4804 | `83668e1c4f` | NEEDS-WORK | Fresh-connection policy must be recomputed after dispatch overrides finalize the URL (`src/server/responses/fetch-helpers.ts:114`). | +| #4803 | `543f1c60a4` | HOLD | A non-terminal text EOF becomes HTTP 200, which can hide genuine truncation (`src/server/chat-native-sse.ts:367`). | +| #4802 | `14c478cda2` | NEEDS-WORK | No product CI at head. | +| #4800 | `af985d3d13` | NEEDS-WORK | 13 commits behind `dev`, past the 10-commit readiness window; needs a refresh and new exact-head CI. | +| #4782 | `76d7452afb` | HOLD | Experimental native steering, 40 files, no test CI and no live smoke. | +| #4781 | `110656662f` | HOLD | Profile-auth UI hangs on refresh (`gui/src/native-main-profile-session.ts:124`). | +| #4753 | `f07c61b3a3` | NEEDS-WORK | Duplicated admission route registry (`src/server/inbound-body-admission.ts:27`). | +| #4751 | `eb6184e98d` | NEEDS-WORK | 504 precedence and event-loop yield need a correctness pass. | +| #4728 | `82b652349d` | HOLD | 28k added lines across four control planes, with an unresolved vault salt finding (`src/credentials/vault.ts:27`). Not release-compatible breadth. | +| #4183, #3983, #3952 | — | HOLD | Each is 950-1150 commits behind `dev` and conflicting. Reconstruction, not review. | + +## Drafts + +`#4560` is the notable one: it is no longer conflicting, sits zero commits behind `dev`, and is a +44-file, 5k-line GUI redesign with no test CI at its head. It is held for the same evidence reason +as the rest, not because of its content. + +`#4817` claims issue #4808 and is the only draft whose logic was disputed on review: as written it +replays ambiguous errors, while `src/server/responses/combo-stream-preflight.ts:160-162` documents +the fail-closed boundary the issue depends on. It needs affirmative retry evidence before it lands. + +`#4783` is titled `[WRONG BRANCH]` and targets `main`, 184 commits behind. Its work is genuinely +unique — `src/web-search/backends.ts:82-89` on `dev` ends at Exa and exports no API-key search +executor — so it should be reopened against `dev` rather than closed as superseded. + +`#4020` is structurally conflicted: a virtual merge from base `94063d0798` conflicts in 11 files +including `src/codex/auth-api.ts`, `src/codex/routing.ts` and `src/config.ts`. + +## Stale contributor pull requests + +Thirteen contributor drafts are both conflicting and five or more days without a substantive +commit: #2280, #2351, #2355, #2562, #3025, #3080, #3282, #3283, #3463, #3738, #4022, #4056, #4225. +#2562's pool and failover behaviour is already generalized on `dev` +(`src/oauth/generic-account-failover.ts:300`), and #3283 still imports a developer-local 2.51.0 +tree (`tests/antigravity-balance.test.ts:2`). + +These are other people's work, so the disposition is an owner decision rather than a triage +outcome, and nothing was closed on triage authority alone. diff --git a/devlog/_plan/260917_2570_release_train/030_issue_triage.md b/devlog/_plan/260917_2570_release_train/030_issue_triage.md new file mode 100644 index 00000000000..c403c458aea --- /dev/null +++ b/devlog/_plan/260917_2570_release_train/030_issue_triage.md @@ -0,0 +1,38 @@ +# wp4 — issue triage + +Every open issue touched in the last week was checked against `dev` `2b19983bfd` in source. The +headline result is the mirror image of the pull-request pass: **almost nothing in the open issue +list is already fixed**, so 2.57.0 does not silently close the backlog. + +## Closed + +| Issue | Why | +| --- | --- | +| #4730 | Fixed on `dev` and unreleased. `src/codex/catalog/aggregation.ts:233-244` now skips an already-emitted slug, consumed at `src/codex/catalog/retained-sync.ts:523`. Landed as `fab7e427c7` (#4799), CI green at `35084581608`. Ships in 2.57.0. | +| #4688 | Working as intended. `deepseek-v4-flash` is a deliberately retained compatibility alias (`src/providers/registry/entries-core.ts:1000-1013`) and `src/codex/catalog/routed-gather.ts:853-870` implements that retention. The roster/inventory mismatch is the policy showing through. The real gap — nothing marks a row as an alias — belongs in its own enhancement. | + +## Linked to the pull request that addresses them + +#4808 to #4817, #4787 to #4788, #4644 to #4649, #4524 and #4521 to #4567. Each comment records +what is still present on `dev` and where, so the link is checkable rather than asserted. + +## Relabelled + +#4810 and #4761 moved from `bug` to `enhancement`: both describe behaviour the code performs +deliberately (`src/codex/inject/config-toml.ts:84-96` writes the hardcoded provider display name; +`src/cli/system-command.ts:132-140` restarts the whole shell by design). #4579 gained +`needs-design`, #4443 gained `chore`. + +## Open and confirmed, no pull request + +#4822 (Z.AI discovery has no `modelDiscovery` override, `src/providers/registry/entries-extended.ts:414-435`), +#4820 (successful Cursor discovery is still filtered by the static seed, `src/adapters/cursor/discovery.ts:240-252`), +#4812, #4811, #4790, #4779, #4780, #4680, #4662, #4646, #4590, #4587. Each carries a file and line +in the triage record above rather than a restatement of the report. + +## Partially fixed, deliberately left open + +#4721, #4582 and #4546 each have a landed piece and a named remainder. For #4546 the remainder is +specific: the workflow cap still derives only from `x-codex-parent-thread-id` +(`src/server/responses/request-send-budget.ts:30-37`) while each child still gets an independent +affinity key (`src/codex/auth-context.ts:99-123`), which is what #4780 tracks. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 329deb984a3..df02daf48bc 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -86,6 +86,7 @@ export default defineConfig({ translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" }, items: [ { label: "Remote Hub Deployment", translations: { fr: "Déploiement Remote Hub", ko: "Remote Hub 배포", "zh-CN": "Remote Hub 部署", "zh-TW": "Remote Hub 部署", ru: "Развёртывание Remote Hub", ja: "Remote Hub のデプロイ", tr: "Remote Hub Dağıtımı" }, slug: "guides/remote-hub" }, + { label: "Response Inspection", slug: "guides/response-inspection" }, { label: "Remote Workspace", translations: { fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, { label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" }, diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index de614880e75..43e718d2da2 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -159,8 +159,9 @@ Support/Claude/configLibrary` sur macOS, `%APPDATA%\Claude\configLibrary` sur Wi `CLAUDE_USER_DATA_DIR` pour utiliser une autre racine de données Claude Desktop. L'ancien répertoire `Claude-3p` n'est ni lu ni supprimé automatiquement. -Les routes non Anthropic reçoivent des alias stables comme `claude-opus-4-8-2026MMDD`. La partie qui ressemble à une date -est un emplacement synthétique de route, et non la date de publication du modèle. Les véritables routes Anthropic Claude conservent +Les routes non Anthropic reçoivent des alias stables comme `claude-opus-4-8-YYYYMMDD`, dont l'année va de 2026 à 2035. La partie qui ressemble à une date +est un emplacement synthétique de route, et non la date de publication du modèle. Les emplacements de 2026 sont attribués en premier, de sorte que les alias +existants conservent leur identifiant ; les années suivantes ne sont utilisées qu'une fois 2026 saturée. Les véritables routes Anthropic Claude conservent leur identité. Les nouvelles routes appartiennent par défaut à la famille Opus, mais déplacer une route ne change ni le fournisseur ni le modèle qu'elle appelle. Les anciens indicateurs `--static`, `--hybrid` et `--discovery-only` restent disponibles pour les scripts existants. 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 9a603b910af..8fff31edaf8 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -421,4 +421,6 @@ Codex. Seule l'exécution explicite de `ocx stop` ou `ocx service stop` restaure Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. Cette raison ne refuse plus la configuration Codex, le profil de référence ni le catalogue de modèles. `ocx sync` et `ocx start` écrivent toujours ces fichiers et définissent `model_catalog_json`, afin que le sélecteur de modèles Codex continue d’afficher tous les modèles routés par OpenCodex. Seule cette raison interrompt le réétiquetage de l’historique des conversations, car Codex attribue les numéros d’historique paginé dans son propre processus d’écriture et aucune nouvelle tentative n’y change rien. Toute autre raison de contrôle préalable de l’historique — une base d’état illisible, un historique dont l’identité a changé, ou un contrôle préalable qui n’a pas pu s’exécuter — refuse encore toute la transition et l’annule, car ces cas peuvent réussir plus tard. Dans cet état, OpenCodex ne modifie jamais les fichiers d’historique paginé ni les lignes de conversation. Les conversations existantes conservent le fournisseur déjà associé et ne sont pas migrées ; les nouvelles conversations passent par le proxy. Lorsque le réétiquetage est interrompu, une table `[model_providers.opencodex]` déjà présente dans le répertoire d’accueil est conservée plutôt que retirée, y compris sous la forme root-override (loopback), afin que les conversations dont les lignes sont étiquetées `opencodex` gardent un identifiant de fournisseur qui existe encore. Le CLI affiche `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` et la suppression de la configuration Codex refusent toujours sur `history_paginated_requires_native_writer`. Retirer la définition `[model_providers.opencodex]` alors que des lignes de conversation la référencent encore rendrait ces conversations irrésolubles, et le chemin de restauration n’a aucun moyen de conserver une table de fournisseur de compatibilité. Un répertoire d’accueil déjà paginé ne peut pas actuellement être désinstallé par le produit ; c’est un travail ouvert connu, et non le comportement voulu. +Lors du retour au mode de remplacement de l’URL racine, OpenCodex conserve la définition `[model_providers.opencodex]` existante avant de valider la configuration, même si la vérification préalable de l’historique réussit. Les anciennes conversations `opencodex` peuvent ainsi toujours retrouver leur fournisseur si Codex migre l’historique après cette validation ou pendant le démarrage du traitement en arrière-plan. Les nouvelles conversations utilisent le fournisseur racine sélectionné ; la restauration explicite conserve ses contrôles de suppression distincts. + Ne réécrivez pas un historique paginé actif ni une ligne de conversation pour forcer une migration. Fermez la conversation avant toute récupération et signalez l’erreur exacte et les versions sans publier de données privées. Une sauvegarde ou le succès d’un script ne prouve pas le rétablissement de l’affichage : vérifiez la conversation après réouverture de Codex. diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index 04ff6ccd443..9535fe02f44 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -131,7 +131,12 @@ Si Kiro s’arrête sans appeler l’outil d’achèvement, l’adaptateur effec ### Effort de raisonnement -`gpt-5.6-sol` et `claude-opus-5` prennent en charge nativement un niveau d’effort vérifié, mais chaque famille de modèles nomme différemment le champ de la requête. La valeur sélectionnée `low`, `medium`, `high`, `xhigh` ou `max` est envoyée dans `additionalModelRequestFields.reasoning.effort` pour `gpt-5.6-sol`, et dans `additionalModelRequestFields.output_config.effort` pour `claude-opus-5`. Les autres modèles Kiro utilisent actuellement un raisonnement émulé : opencodex convertit le niveau choisi en instructions de réflexion bornées dans le contenu utilisateur, car leur champ d’effort natif n’a pas été vérifié. La présence d’un contrôle d’effort annoncé sur ces modèles ne prouve donc pas la prise en charge native du raisonnement en amont. +Les modèles GPT-5.6 utilisent `additionalModelRequestFields.reasoning.effort`, et `claude-opus-5` +utilise `additionalModelRequestFields.output_config.effort`. Pour `gpt-5.6-luna` et `gpt-5.6-terra`, +seuls `low`, `medium`, `high` et `max` empruntent le chemin natif vérifié. Leur niveau `xhigh` +conserve les instructions de réflexion bornées existantes, car ce niveau natif n’a pas été vérifié. +`gpt-5.6-sol` et `claude-opus-5` conservent leurs niveaux natifs existants : `low`, `medium`, `high`, +`xhigh` et `max`. Les autres modèles Kiro utilisent une émulation ; un réglage d’effort ne prouve pas une prise en charge native. ## `cursor` diff --git a/docs-site/src/content/docs/fr/reference/architecture.md b/docs-site/src/content/docs/fr/reference/architecture.md index 746ea7f7265..14885a808ee 100644 --- a/docs-site/src/content/docs/fr/reference/architecture.md +++ b/docs-site/src/content/docs/fr/reference/architecture.md @@ -21,7 +21,8 @@ src/ ├── vision/ # service auxiliaire de vision (description et planification) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -32,19 +33,19 @@ src/ └── index.ts # public entry ``` -Trois anciens points d’entrée volumineux préservent désormais la compatibilité sous forme de façades : `codex/catalog.ts` exporte les sept modules spécialisés `codex/catalog/*.ts`, `server/management-api.ts` répartit les requêtes entre les neuf modules `server/management/*.ts`, et `server/responses.ts` exporte les cinq modules `server/responses/*.ts`. +Les anciens points d’entrée volumineux préservent désormais la compatibilité sous forme de façades : `codex/catalog.ts` exporte les modules `codex/catalog/*.ts`, `server/management-api.ts` répartit les requêtes entre les modules `server/management/*.ts`, `server/responses.ts` exporte les modules `server/responses/*.ts`, et `bridge.ts` réexporte les modules `bridge/*.ts`. Une façade est le chemin d’import stable, pas l’implémentation : chaque étape ci-dessous nomme le module qui détient le code, et `structure/transports/responses.md` contient l’inventaire complet des propriétaires de la surface Responses. ## Flux d’une requête -`server/index.ts` gère la frontière HTTP et délègue le plan de données Responses à la façade `server/responses.ts` et à ses modules `server/responses/*.ts` : +`server/index/serve-options.ts` gère la frontière HTTP et délègue le plan de données Responses à la façade `server/responses.ts` et à ses modules `server/responses/*.ts` : -1. `server/index.ts` applique CORS et l’authentification d’API, refuse les nouvelles tâches pendant le drainage et enregistre les métadonnées du cycle de vie de la requête. Il sert `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (relayés vers une famille OpenAI en amont par `server/images.ts` pour l’outil `image_gen` intégré à Codex), `POST /v1/live` / `POST /v1/realtime/calls` (création des appels vocaux ChatGPT / Codex App et OpenAI Realtime, relayée par `server/live.ts`), les connexions WebSocket sideband sur `/v1/live/{callId}` (et `/v1/realtime?call_id=`), ainsi que la mise à niveau WebSocket facultative sur `/v1/responses`. -2. `server/responses/core.ts` décompresse et analyse le JSON, développe les entrées de mémoire locale `previous_response_id` lorsqu’elles sont disponibles, puis appelle `responses/parser.ts`. +1. `server/index/serve-options.ts` applique CORS et l’authentification d’API, refuse les nouvelles tâches pendant le drainage et enregistre les métadonnées du cycle de vie de la requête. Il sert `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (relayés vers une famille OpenAI en amont par `server/images.ts` pour l’outil `image_gen` intégré à Codex), `POST /v1/live` / `POST /v1/realtime/calls` (création des appels vocaux ChatGPT / Codex App et OpenAI Realtime, relayée par `server/live.ts`), les connexions WebSocket sideband sur `/v1/live/{callId}` (et `/v1/realtime?call_id=`), ainsi que la mise à niveau WebSocket facultative sur `/v1/responses`. +2. `server/responses/request-prepare.ts` décompresse et analyse le JSON, développe les entrées de mémoire locale `previous_response_id` lorsqu’elles sont disponibles, puis appelle `responses/parser.ts`. 3. `router.ts` résout un identifiant simple ou `provider/model`. Le serveur détermine ensuite l’affinité du compte Codex, actualise l’authentification OAuth du fournisseur si nécessaire et applique à la route les identifiants sélectionnés. 4. Avant l’appel principal, `vision/` décrit les images pour les modèles figurant dans `noVisionModels`. En l’absence de service auxiliaire sûr, les images sont supprimées plutôt qu’envoyées à un service en amont purement textuel. 5. `server/adapter-resolve.ts` applique toute substitution de protocole propre au modèle et construit l’un des adaptateurs enregistrés. L’adaptateur Responses relaie le corps natif, Cursor exécute son transport bidirectionnel `runTurn`, et les adaptateurs traduits construisent, envoient et analysent une requête en amont. 6. Pour les modèles routés avec un outil hébergé `web_search`, `web-search/` expose une fonction synthétique, exécute la recherche réelle avec le backend configuré — le service auxiliaire OpenAI/ChatGPT ou le backend Anthropic —, renvoie les résultats au modèle routé et recommence dans la limite de boucle configurée. Cette boucle ne prend en charge que le chemin HTTP classique ; les adaptateurs qui implémentent `runTurn`, comme Cursor, la contournent et poursuivent leur propre transport. -7. `bridge.ts` produit un flux SSE Responses ou une réponse JSON. `server/request-log.ts` et `usage/` recueillent de manière bornée l’état, la latence, les libellés de fournisseur/modèle et l’utilisation estimée des jetons, sans modifier la réponse. +7. `bridge/sse.ts` / `bridge/response-json.ts` produit un flux SSE Responses ou une réponse JSON. `server/request-log.ts` et `usage/` recueillent de manière bornée l’état, la latence, les libellés de fournisseur/modèle et l’utilisation estimée des jetons, sans modifier la réponse. ## Analyseur @@ -57,7 +58,7 @@ Trois anciens points d’entrée volumineux préservent désormais la compatibil ## Pont -`bridge.ts` transforme le flux interne `AdapterEvent` de l’adaptateur en événements SSE Responses compris par Codex : +`bridge/sse.ts` transforme le flux interne `AdapterEvent` de l’adaptateur en événements SSE Responses compris par Codex : | AdapterEvent | Événements SSE Responses émis | | --- | --- | @@ -85,7 +86,7 @@ Les implémentations OAuth se trouvent dans `oauth/`. Les jetons d’accès sont ## Transport et compactage -Par défaut, `server/index.ts` sert HTTP/SSE sur `/v1/responses`. Si Codex tente une mise à niveau WebSocket de Responses alors que `websockets` vaut `false`, opencodex renvoie `426 upgrade_required` ; Codex revient alors à HTTP pour cette session. Lorsque `"websockets": true` est défini, le même point de terminaison accepte la mise à niveau et utilise le pont WebSocket. +Par défaut, `server/index/serve-options.ts` sert HTTP/SSE sur `/v1/responses`. Si Codex tente une mise à niveau WebSocket de Responses alors que `websockets` vaut `false`, opencodex renvoie `426 upgrade_required` ; Codex revient alors à HTTP pour cette session. Lorsque `"websockets": true` est défini, le même point de terminaison accepte la mise à niveau et utilise le pont WebSocket. Indépendamment de ce réglage côté client, les requêtes canoniques transmises à ChatGPT avec `stream: true` à la racine peuvent utiliser le transport WebSocket en amont de Codex avec une version stable de Bun 1.4.0 ou ultérieure. La version intégrée Bun 1.3.14, les préversions et les identités de runtime impossibles à vérifier utilisent HTTP/SSE. Les réponses WS en amont qui réussissent conservent le contrat SSE en aval et contournent `tee()` au moyen d’un relais borné à lecteur unique et avide (4 MiB par trame brute/enveloppée et une file de production de 8 MiB). Le dépassement de la file ferme la connexion en amont et émet en aval un événement terminal `response.failed`, suivi de `[DONE]`. @@ -98,7 +99,7 @@ l’ancien socket ; les requêtes admissibles suivantes ayant la même identité le nouveau socket. Les autres modèles et passerelles conservent leur politique Lite. Des métadonnées natives mal formées entraînent toujours un repli HTTP, sans modifier le corps. -Le compactage du contexte Codex fonctionne avec les modèles routés. `server/responses/compact.ts` traite `POST /v1/responses/compact` en exécutant un tour interne de synthèse routé et en renvoyant un historique compacté, tandis que `responses/parser.ts` et `bridge.ts` traitent les tours de compactage distant v2 `compaction_trigger` en émettant exactement un élément de sortie synthétique `compaction`. +Le compactage du contexte Codex fonctionne avec les modèles routés. `server/responses/compact.ts` traite `POST /v1/responses/compact` en exécutant un tour interne de synthèse routé et en renvoyant un historique compacté, tandis que `responses/parser.ts` et `bridge/sse.ts` traitent les tours de compactage distant v2 `compaction_trigger` en émettant exactement un élément de sortie synthétique `compaction`. ## Mise en cache et catalogue diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index 5b333ad44ae..2394bf5e422 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -17,7 +17,7 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif. -`ocx system codex-cli-update check` ne nécessite aucun proxy actif et n’interroge aucun registre de paquets. La commande inspecte, dans des limites strictes, les métadonnées de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable`. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’installe ni ne répare de logiciel, n’exécute ni Codex ni npm, ne contrôle aucun processus actif et n’écrit aucun état de configuration ou de cache. +`ocx system codex-cli-update check` ne nécessite aucun proxy actif et n’interroge aucun registre de paquets. La commande inspecte, dans des limites strictes, les métadonnées de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable` sous POSIX ou `windows_inspection_deferred` sous Windows. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’installe ni ne répare de logiciel, n’exécute ni Codex ni npm, ne contrôle aucun processus actif et n’écrit aucun état de configuration ou de cache. L’affichage d’une liste ou d’un état est l’action par défaut lorsqu’il n’y a aucune ambiguïté. Utilisez `--json` pour obtenir des instantanés structurés et `ocx observe logs --follow --jsonl` pour suivre un flux de journaux de requêtes. Le thème, la langue, la navigation et les autres états purement visuels du navigateur n’ont pas d’équivalent dans la CLI. La configuration de Cloudflare Tunnel ne fait pas partie de cet ensemble de commandes. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 31dcb9ac94d..837f1cdb9b4 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -270,7 +270,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` n’interroge aucun registre de paquets et inspecte, dans des limites strictes, les éléments de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable`. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’exécute ni Codex ni aucun gestionnaire de paquets, ne répare aucun shim, n’écrit ni dans la configuration ni dans le cache, n’arrête aucun processus et n’installe rien. Les candidats intégrés à une application, issus d’un gestionnaire de versions reconnu, autonomes mais non vérifiés, ou associés à un état de shim ambigu sont signalés comme non gérés ou inconnus et ne sont jamais classés comme gérés. +`check` n’interroge aucun registre de paquets et inspecte, dans des limites strictes, les éléments de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable` sur les systèmes POSIX. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. Comme cette étape ne consulte jamais l’état persistant, une exécution Windows pour laquelle aucun candidat d’environnement n’a été capturé signale `windows_inspection_deferred` plutôt que `candidate_unavailable` : la commande ne peut pas observer si une CLI Codex est installée, elle signale donc le report de l’inspection au lieu d’affirmer qu’aucun candidat n’existe. La commande n’exécute ni Codex ni aucun gestionnaire de paquets, ne répare aucun shim, n’écrit ni dans la configuration ni dans le cache, n’arrête aucun processus et n’installe rien. Les candidats intégrés à une application, issus d’un gestionnaire de versions reconnu, autonomes mais non vérifiés, ou associés à un état de shim ambigu sont signalés comme non gérés ou inconnus et ne sont jamais classés comme gérés. + +Sous Windows, une commande simple capturée comme `CODEX_CLI_PATH=codex`, un chemin distant ou un chemin de périphérique produit plutôt `candidate_path_unavailable`. Le candidat a été capturé, mais son chemin ne convient pas à cette inspection. ### `ocx config ...` diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 73b3c6d0657..152108fd881 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -170,6 +170,8 @@ redirections, les réponses trop volumineuses et les catalogues invalides sont r écriture locale. L'authentification est facultative et lue uniquement par référence à une variable d'environnement (`--auth-env`), jamais depuis argv. +Les requêtes HTTP en loopback sont refusées avant l’ajout des en-têtes d’authentification ou tout envoi si `HTTP_PROXY` ou `http_proxy` s’applique sans exception correspondante dans `NO_PROXY` ou `no_proxy`. `ALL_PROXY`/`all_proxy` et les paramètres limités à `HTTPS_PROXY`/`https_proxy` ne déclenchent pas cette restriction HTTP ; l’acquisition de catalogues en HTTPS reste autorisée. Le message de refus ne contient ni l’adresse du proxy ni le jeton d’authentification. Les valeurs non vides de `http_proxy` et `no_proxy` ont priorité sur `HTTP_PROXY` et `NO_PROXY`, respectivement. Pour des exceptions compatibles avec Bun, utilisez des noms d’hôte, des entrées `host:port` correspondantes, des adresses IPv6 entre crochets comme `[::1]`, ou `*`, sans URL, chemin ni préfixe `*.`. + Le catalogue et le cache sont écrits sous le verrou de catalogue Codex partagé ; un échec préserve les derniers fichiers valides connus. Des octets identiques constituent une non-opération qui préserve les mtimes. `--restart-codex`, `--restart-app-server-only` et l'alias déprécié diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index afa6fc19ec7..b6ff94de9f1 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -321,6 +321,8 @@ ocx account main switch --yes [--json] ocx account main recover [--rollback --yes] [--json] ``` +En cas de succès, `ocx account main reauth --device --no-wait --json` écrit un seul objet JSON sur stdout, sans la ligne destinée à la lecture humaine `follow up:`. Utilisez son `flowId` avec `ocx account main reauth status --flow --json` pour suivre la progression. + Chaque commande de mutation rapporte le `CODEX_HOME` effectif canonique renvoyé par le proxy en cours d'exécution. Ce chemin peut différer du `CODEX_HOME` de l'appelant ; les commandes qui prennent en charge JSON exposent le même valeur comme `effectiveCodexHome`. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index a44867bd09c..6d87b8a2ce5 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -181,8 +181,11 @@ Support/Claude/configLibrary` on macOS, `%APPDATA%\Claude\configLibrary` on Wind `CLAUDE_USER_DATA_DIR` for an alternate Desktop user-data root. The legacy `Claude-3p` directory is not read or deleted automatically. -Non-Anthropic routes receive stable aliases such as `claude-opus-4-8-2026MMDD`. The date-looking -part is a synthetic route slot, not the model's release date. Real Anthropic Claude routes keep +Non-Anthropic routes receive stable aliases such as `claude-opus-4-8-YYYYMMDD`, where the year runs +from 2026 to 2035. The date-looking +part is a synthetic route slot, not the model's release date. 2026 slots are allocated first, so +existing aliases keep their ids; the later years are reached only once 2026 fills. +Real Anthropic Claude routes keep their real ids. New routes default to the Opus family, but moving a route does not change the provider or model it calls. The legacy apply flags `--static`, `--hybrid`, and `--discovery-only` remain available for existing scripts. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 877117babe3..65e1201acc1 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -807,6 +807,8 @@ Catalog sync makes the selected sub-agent models available to Codex; see [Codex 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.6-luna`, and retries with `gpt-5.5` on HTTP 400 or HTTP 404. Public errors contain fixed failure categories rather than raw upstream response bodies. +An HTTP 429 from an attempted warmup is reported as `codex_warmup_rate_limited`. Retry after the temporary restriction clears or the usage limit resets; signing in again does not reset these limits. A failed attempted warmup does not add a new account or replace existing credentials. This differs from quota-confirmed deferred registration below, which can save a restricted account without a model request. HTTP 401/403 failures retain the authentication guidance. + 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. `ocx account refresh openai` and `ocx account list openai --quota --refresh` only read usage. Model validation spends quota and requires a human dashboard session: open `ocx gui` and click **Refresh quotas** after recovery. For a headless host, access its dashboard from your browser; an admin token alone does not authorize validation. Validation can complete while an account is paused without resuming or selecting it. Model authorization failures remain visible until successful validation or reauthentication clears them. @@ -875,6 +877,8 @@ When a routed preferred model may receive V2 work from a native ChatGPT parent, When an affected history store supports paginated records, a provider transition may return `history_paginated_requires_native_writer`. That reason no longer refuses the Codex configuration, the reference profile, or the model catalog. `ocx sync` and `ocx start` still write those files and set `model_catalog_json`, so the Codex model picker keeps showing every OpenCodex-routed model. Only this one reason stands the conversation-history relabel down, because Codex allocates paginated rollout ordinals in its own writer and no retry changes that. Any other history preflight reason — an unreadable state database, a rollout whose identity changed, or a preflight that could not run — still refuses the whole transition and rolls it back, because those may succeed on a later attempt. OpenCodex never modifies paginated rollout files or thread rows in this state. Existing conversations keep whatever provider they are already tagged with and are not migrated; new conversations route through the proxy normally. When the relabel stands down, a `[model_providers.opencodex]` table that the home already had is kept rather than retired, even in the root-override (loopback) form, so conversations whose rows are tagged `opencodex` keep a provider id that still exists. This includes legacy rows in a migration-capable store. The CLI prints `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. +When returning to the root-override form, OpenCodex retains an existing `[model_providers.opencodex]` definition before committing the configuration, even if history preflight currently passes. This keeps older `opencodex` conversations resolvable if Codex migrates history after that commit or while the background worker starts. New conversations still use the selected root provider; explicit restore keeps its separate removal guards. + `ocx restore` and Codex config removal still refuse on `history_paginated_requires_native_writer`. Stripping the `[model_providers.opencodex]` definition while thread rows still reference it would make those conversations unresolvable, and the restore path has no way to keep a compatibility provider table. A home that is already paginated cannot currently be uninstalled through the product; that is known open work rather than intended behaviour. Do not rewrite an active paginated rollout or thread row to migrate those conversations yourself. Close the affected conversation before any recovery, and report the exact error and versions without uploading private history. A backup or a successful script alone does not prove the conversation is visible again. Check the restored conversation in Codex after reopening. diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 6018d7e1bdf..b3bfd8f65bb 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -83,7 +83,10 @@ The request then follows normal combo selection and failover. Explicit provider/combo selectors and configured combo aliases take precedence over this recall. Failed, incomplete, or cancelled responses do not replace the last successful selection. Recall is -process-local and bounded to 256 lanes for 30 minutes; it does not store account credentials. +process-local and bounded to 256 conversations for 30 minutes, and to 1 KiB per remembered model +name and 64 KiB in total; expired entries are also cleaned up in the background. A response whose +model name is too large to retain leaves the previous selection untouched rather than clearing it. +Recall does not store account credentials. Without usable conversation identity or valid remembered state, normal compaction routing applies. A restart clears the remembered state. @@ -263,10 +266,11 @@ A combo can also advance after an intact HTTP 400 `invalid_request_error` that s ## Default reasoning effort -`defaultEffort` fills an absent `reasoning.effort` when the combo has a non-null default and the selected target has a known, nonempty supported ladder. If the target supports the configured value, it is retained; otherwise the highest supported rung at or below it is used, or the lowest supported rung when none is lower. Unknown or empty ladders omit the default. +`defaultEffort` supplies a configured effort when the selected target has a known, nonempty supported ladder. With the default `defaultEffortMode: "fallback"`, an explicit caller effort keeps precedence. `defaultEffortMode: "force"` overrides a valid caller effort with the configured default; it requires a valid, non-null `defaultEffort` and can increase cost and latency. Force mode is an explicit operator choice through combo configuration or management. -The default-injection step preserves existing effort and other reasoning fields. Capability normalization can separately remove unsupported effort/thinking controls as described below. Supported defaults are `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`; omit the field or use `null` to disable default injection. +The target's advertised ladder remains authoritative. An exact supported value is retained; otherwise the highest supported rung at or below it is selected, or the lowest supported rung when none is lower. Unknown or empty ladders never cause default injection. Force mode does not repair malformed caller effort into a valid expensive request. Other reasoning fields, including `reasoning.summary`, are preserved. +`reasoningEffortMode` remains independent of `defaultEffortMode`: explicit empty ladders remove unsupported effort/thinking controls, and adaptive unknown ladders do so as well, as described below. Strict unknown ladders preserve the caller's request without forcing a default. Supported defaults are `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`; omit `defaultEffort` or set it to `null` to disable default injection in fallback mode. ### Mixed-capability groups (`reasoningEffortMode`) @@ -413,7 +417,8 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. Applies only to round-robin. | | `cooldownMs` | No | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Integer from 1 to 600000. When set, applies as the per-target cooldown whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. | | `waitForCooldownMs` | No | `0` | Integer from 0 to 600000. Maximum time to wait for the earliest eligible cooling target before returning `combo_unavailable`; abort cancels the wait. | -| `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. | +| `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; resolved against each target's advertised ladder. | +| `defaultEffortMode` | No | `"fallback"` | `"fallback"` preserves explicit caller effort. `"force"` overrides valid caller effort, requires a valid non-null default, and can increase cost and latency. | | `reasoningEffortMode` | No | `"strict"` | `"strict"` intersects every known target ladder, so one target advertising no effort control empties the combo's picker. `"adaptive"` excludes those empty ladders from the published intersection. At dispatch, explicit empty or adaptive unknown ladders remove unsupported effort/thinking controls while preserving supported non-effort reasoning fields such as `reasoning.summary`; known non-empty targets keep existing effort resolution. | | `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"auto"` publishes image support only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). | | `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f45f653c395..94c006e724f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -64,6 +64,18 @@ Shipped v1 configs migrate automatically to marker 2 and one option-aware row. T is retained once at `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; restore it with `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`. +## Anthropic image input + +The built-in Claude model seeds advertise text and image input for both `anthropic` (OAuth) and +`anthropic-apikey`, consistent with [Anthropic's model overview](https://platform.claude.com/docs/en/models/overview). +Explicit per-model input-modality overrides remain authoritative; unknown models are not assumed +image-capable. This applies across integrations wherever the client's configuration supports image +capability metadata: OpenClaw exports a declared `input` array, and Kimi Code exports +`capabilities: ["image_in"]` only for image-capable models. OpenClaw omits `input` when no supported +modalities are declared; Kimi omits `capabilities` for unknown or text-only models. Clients without +a supported capability field keep their existing configuration shape. After updating opencodex, +regenerate or refresh the client configuration managed by opencodex to receive the updated metadata. + ## Auth modes Provider configs accept three `authMode` values (`key` is the default). The built-in registry also @@ -759,7 +771,7 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C - Global: [CodeBuddy Global API Keys](https://www.codebuddy.ai/profile/keys) - CN: [CodeBuddy CN API Keys](https://copilot.tencent.com/profile/keys) - **Region Isolation:** `codebuddy` and `codebuddy-cn` use separate canonical endpoints (`https://www.codebuddy.ai` and `https://www.codebuddy.cn`) and isolated child environments (`CODEBUDDY_INTERNET_ENVIRONMENT=public` vs `internal`). Credentials are strictly region-scoped and never exchanged across environments. Overriding the canonical base URL fails closed. -- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. +- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text. - **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement. ### Official Qoder CLI (Global & CN) diff --git a/docs-site/src/content/docs/guides/response-inspection.md b/docs-site/src/content/docs/guides/response-inspection.md new file mode 100644 index 00000000000..e1ac72b3b50 --- /dev/null +++ b/docs-site/src/content/docs/guides/response-inspection.md @@ -0,0 +1,36 @@ +--- +title: Response inspection and large responses +description: How bounded diagnostic retention and streaming inspection interact with response delivery. +--- + +OpenCodex keeps response diagnostics bounded without making the logging limit a +limit on the bytes delivered to your client. Other provider, request and transport +limits still apply independently. + +## JSON and ordinary error responses + +JSON inspection retains at most 32 MiB of source bytes. If the body exceeds that +allowance, logging drops its retained copy and continues forwarding the original +response. It does not parse a truncated prefix as authoritative usage or model +metadata. Usage already supplied by another trusted path is preserved; missing +usage is not replaced with an invented zero. Ordinary non-JSON error diagnostics +retain only the first 8 KiB and pass through the existing redaction logic. + +The client receives chunks as it reads them rather than waiting for diagnostic +inspection of the whole body. A read failure is recorded as 502 and cancellation +as 499 in request history; these diagnostic outcomes do not rewrite HTTP headers +that have already been sent. Logging is finalized once. + +## Streaming responses + +Native SSE inspection pauses when it runs too far ahead of client consumption. +The allowance is 32 MiB plus source-chunk/native-prefetch overhead, not a total +response-size limit or a cap on all process memory. A longer response is still +inspected through its actual completion event, including terminal usage and +continuation state. + +After the client disconnects, the existing bounded drain can still observe a late +completion for up to 15 seconds or 32 MiB of additional inspection. A forced +shutdown is different: it discards uncompleted candidates rather than recording +them as a completed response. Existing transport selection and WebSocket memory +bounds are unchanged. No new configuration setting is required. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index ef987bae787..9c62d662306 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -352,3 +352,7 @@ is gated correctly without manual classification. Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. + +### Usage chart keyboard and touch controls + +Usage heatmap days have one Tab entry point. Use Up/Down for adjacent days and Left/Right for adjacent weeks. Weekly bars expose the same day details on keyboard focus, pointer hover, or touch. Day labels include the date, request count, and token count; tooltip overlays stay within the viewport. 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 cc44ec28c3e..4f6afdb37e9 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -283,4 +283,6 @@ opencodex が管理対象 [バックグラウンドサービス](/reference/cli/ 対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` を返すことがあります。この理由では、Codex の設定、参照プロファイル、モデルカタログは拒否されません。`ocx sync` と `ocx start` はこれらのファイルを書き込み、`model_catalog_json` を設定するため、Codex のモデル選択には OpenCodex 経由のモデルがすべて表示され続けます。会話履歴の再ラベル付けを控えるのはこの理由だけの場合です。ページ分割された履歴の番号は Codex 自身の書き込み処理が割り当て、再試行しても変わりません。読み取れない状態データベース、識別子が変わった履歴、実行できなかった事前検査など、それ以外の履歴事前検査の理由では、後から成功する可能性があるため、遷移全体を拒否してロールバックします。この状態では OpenCodex はページ分割された履歴ファイルやスレッド行を変更しません。既存の会話はすでに付いているプロバイダーのまま移行されず、新しい会話は通常どおりプロキシ経由でルーティングされます。再ラベル付けを控えるとき、ホームに既にある `[model_providers.opencodex]` テーブルは廃止せず残します。ルート上書き(loopback)形式でも同じで、行が `opencodex` と付いている会話は、まだ存在するプロバイダー id を保てます。移行可能なストアの legacy 行も対象です。CLI は `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)` と表示します。`ocx restore` と Codex 設定の削除は、いまも `history_paginated_requires_native_writer` で拒否されます。スレッド行がまだ参照しているのに `[model_providers.opencodex]` 定義を外すと、それらの会話は解決できなくなり、復元経路には互換プロバイダー表を残す手段がありません。すでにページ分割されているホームは、現状では製品からアンインストールできません。意図した動作ではなく、既知の未解決作業です。 +ルート URL 上書き方式に戻すとき、履歴の事前確認が成功していても、OpenCodex は設定を確定する前に既存の `[model_providers.opencodex]` 定義を保持します。確定後やバックグラウンドの履歴処理開始中に Codex が履歴形式を移行しても、以前の `opencodex` 会話はプロバイダーを引き続き解決できます。新しい会話は選択されたルートプロバイダーを使い、明示的な復元には従来の個別の削除チェックが適用されます。 + 会話を移行しようとして使用中のページ分割履歴やスレッド行を書き換えないでください。復元前に対象の会話を閉じ、個人の履歴を公開せず正確なエラーとバージョンを報告してください。バックアップやスクリプトの成功だけでは表示の復元は証明されません。再度開いた Codex で確認してください。 diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index f1276511b1d..1565770c954 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -154,10 +154,12 @@ filtered incomplete になります。実際のツール呼び出しを伴わな ### Reasoning effort -`gpt-5.6-sol` と `claude-opus-5` はネイティブ effort をサポートし、リクエストフィールド名が異なります。 -`low` / `medium` / `high` / `xhigh` / `max` は、前者では -`additionalModelRequestFields.reasoning.effort`、後者では `output_config.effort` として送信されます。 - +GPT-5.6 系は `additionalModelRequestFields.reasoning.effort`、`claude-opus-5` は +`additionalModelRequestFields.output_config.effort` を使用します。`gpt-5.6-luna` と +`gpt-5.6-terra` では、検証済みの `low`、`medium`、`high`、`max` だけをネイティブフィールドで送信します。 +両モデルの `xhigh` は未検証のため、従来の上限付き thinking 指示によるエミュレーションを維持します。 +`gpt-5.6-sol` と `claude-opus-5` の既存のネイティブ段階(`low`、`medium`、`high`、`xhigh`、`max`)は変更しません。 +その他の Kiro モデルはエミュレーションを使用し、effort の選択肢だけではネイティブ対応を意味しません。 ## `cursor` diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index 3500cbaf869..a803cd97843 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -21,7 +21,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -32,30 +33,33 @@ src/ └── index.ts # public entry ``` -以前の大規模なエントリーファイル 3 つは、現在は互換性 facade です。`codex/catalog.ts` は -7 個の `codex/catalog/*.ts` モジュールを、`server/management-api.ts` は 9 個の -`server/management/*.ts` モジュールを、`server/responses.ts` は 5 個の -`server/responses/*.ts` モジュールを接続します。 +大規模だったエントリーファイルは、現在は互換性 facade です。`codex/catalog.ts` は +`codex/catalog/*.ts` モジュールを、`server/management-api.ts` は +`server/management/*.ts` モジュールを、`server/responses.ts` は +`server/responses/*.ts` モジュールを、`bridge.ts` は `bridge/*.ts` モジュールを接続します。 +facade は安定した import パスであって実装ではありません。以下の各ステップは実際に +コードを所有するモジュールを示し、Responses 面の完全な所有権一覧は +`structure/transports/responses.md` にあります。 ## リクエスト処理フロー -HTTP の境界は `server/index.ts` が担い、Responses データプレーンは `server/responses.ts` facade と +HTTP の境界は `server/index/serve-options.ts` が担い、Responses データプレーンは `server/responses.ts` facade と `server/responses/*.ts` モジュールに渡します。 -1. `server/index.ts` で CORS と API 認証を確認し、終了待ち状態なら新規リクエストを拒否したのち、リクエストのライフサイクルを記録します。ここで `GET /v1/models`、`POST /v1/responses`、 +1. `server/index/serve-options.ts` で CORS と API 認証を確認し、終了待ち状態なら新規リクエストを拒否したのち、リクエストのライフサイクルを記録します。ここで `GET /v1/models`、`POST /v1/responses`、 `POST /v1/responses/compact`、`POST /v1/images/generations` / `POST /v1/images/edits` (Codex 組み込み `image_gen` ツール用 — `server/images.ts` が OpenAI 系の上流に中継)、 `POST /v1/live` / `POST /v1/realtime/calls`(ChatGPT / Codex App 音声と OpenAI Realtime の call-create、`server/live.ts` が中継)と `/v1/live/{callId}` サイドバンド WebSocket、 `/v1/responses` のオプション WebSocket アップグレードを提供します。 -2. `server/responses/core.ts` が展開し JSON を読みます。覚えておいた `previous_response_id` 入力があれば展開したのち `responses/parser.ts` に渡します。 +2. `server/responses/request-prepare.ts` が展開し JSON を読みます。覚えておいた `previous_response_id` 入力があれば展開したのち `responses/parser.ts` に渡します。 3. `router.ts` が通常のモデル id または `provider/model` id を解決します。続いて Codex アカウント affinity を決定し、必要ならプロバイダー OAuth を更新して選択された認証情報を route に適用します。 4. 本リクエストの前に `vision/` が `noVisionModels` モデル用の画像説明を作ります。安全なサイドカー経路がないときはテキスト専用の上流に画像を送らず取り除きます。 5. `server/adapter-resolve.ts` がモデル別の wire override を適用し、登録済みアダプターのいずれかを作ります。 Responses passthrough は元の body を中継し、Cursor は双方向 `runTurn` transport を使い、 残りの変換型アダプターは上流リクエストを build/fetch/parse します。 6. ルーティングモデルがホステッド `web_search` を要求すると `web-search/` が合成関数を公開します。実際の検索は ChatGPT サイドカーで実行し、結果をルーティングモデルに戻し、設定された回数の中で繰り返します。 -7. `bridge.ts` が Responses SSE または JSON を作ります。`server/request-log.ts` と `usage/` はレスポンスに触れずに終了ステータス、レイテンシー、プロバイダー/モデル、最善推定トークン使用量を記録します。 +7. `bridge/sse.ts` / `bridge/response-json.ts` が Responses SSE または JSON を作ります。`server/request-log.ts` と `usage/` はレスポンスに触れずに終了ステータス、レイテンシー、プロバイダー/モデル、最善推定トークン使用量を記録します。 ## パーサー @@ -73,7 +77,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン ## ブリッジ -`bridge.ts` はアダプターの内部 `AdapterEvent` ストリームを Codex が理解する Responses SSE に再変換します: +`bridge/sse.ts` はアダプターの内部 `AdapterEvent` ストリームを Codex が理解する Responses SSE に再変換します: | AdapterEvent | Responses SSE emitted | | --- | --- | @@ -95,7 +99,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン ## 伝送と compaction -`server/index.ts` はデフォルトで `/v1/responses` を HTTP/SSE で提供します。`websockets` が `false` の状態で Codex が Responses WebSocket アップグレードを試みると、opencodex は `426 upgrade_required` を返し、Codex はそのセッションで HTTP にフォールバックします。`"websockets": true` を設定すると同じエンドポイントがアップグレードを受け入れ WebSocket ブリッジを使います。 +`server/index/serve-options.ts` はデフォルトで `/v1/responses` を HTTP/SSE で提供します。`websockets` が `false` の状態で Codex が Responses WebSocket アップグレードを試みると、opencodex は `426 upgrade_required` を返し、Codex はそのセッションで HTTP にフォールバックします。`"websockets": true` を設定すると同じエンドポイントがアップグレードを受け入れ WebSocket ブリッジを使います。 最終送信モデルが `gpt-5.3-codex-spark` の場合、canonical ChatGPT 転送は HTTP ヘッダーと ネイティブ WS フレームのメタデータの両方で Responses Lite を明示的に無効にします。 @@ -108,7 +112,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン Codex コンテキスト compaction はルーティングされたモデルでも動作します。`server/responses/compact.ts` は `POST /v1/responses/compact` を内部ルーティング要約ターンとして扱い、圧縮されたヒストリーを返します。 -`responses/parser.ts` と `bridge.ts` は remote compaction v2 の `compaction_trigger` ターンを扱い、合成 `compaction` 出力項目を正確に 1 つ送ります。 +`responses/parser.ts` と `bridge/sse.ts` は remote compaction v2 の `compaction_trigger` ターンを扱い、合成 `compaction` 出力項目を正確に 1 つ送ります。 ## キャッシュとカタログ diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 1279a037135..4b4997acdf3 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -20,7 +20,7 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ 管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。 -`ocx system codex-cli-update check` は稼働中のプロキシを必要とせず、パッケージレジストリにも問い合わせません。設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴メタデータを、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、`candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドはソフトウェアのインストールや修復、Codex または npm の実行、稼働中プロセスの制御、設定やキャッシュ状態への書き込みを行いません。 +`ocx system codex-cli-update check` は稼働中のプロキシを必要とせず、パッケージレジストリにも問い合わせません。設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴メタデータを、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、POSIX では `candidate_unavailable`、Windows では `windows_inspection_deferred` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドはソフトウェアのインストールや修復、Codex または npm の実行、稼働中プロセスの制御、設定やキャッシュ状態への書き込みを行いません。 リストまたはステータスは、明確なデフォルトです。構造化スナップショットには `--json` を使用し、ストリーミング リクエスト ログ フィードには `ocx observe logs --follow --jsonl` を使用します。テーマ、言語、ナビゲーション、その他の純粋に視覚的なブラウザーの状態には、同等の CLI がありません。 Cloudflare Tunnel のセットアップはこのコマンド セットの外にあります。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 29dc8cd730f..1290b656616 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -196,7 +196,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` はパッケージレジストリに問い合わせず、設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴情報を、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、`candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドは Codex やパッケージマネージャーの実行、shim の修復、設定やキャッシュ状態への書き込み、プロセスの停止、インストールを行いません。アプリ同梱、認識済みのバージョンマネージャー、未検証のスタンドアロン、曖昧な shim の各候補は管理対象外または不明として報告され、管理対象と判定されることはありません。 +`check` はパッケージレジストリに問い合わせず、設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴情報を、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、POSIX では `candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このスライスは永続化された選択状態を一切読み取らないため、環境候補がキャプチャされていない Windows 実行では `candidate_unavailable` ではなく `windows_inspection_deferred` を報告します。コマンドは Codex CLI が導入されているかどうかを観測できないので、候補が存在しないと断定せず、検査が延期されたことを報告します。このコマンドは Codex やパッケージマネージャーの実行、shim の修復、設定やキャッシュ状態への書き込み、プロセスの停止、インストールを行いません。アプリ同梱、認識済みのバージョンマネージャー、未検証のスタンドアロン、曖昧な shim の各候補は管理対象外または不明として報告され、管理対象と判定されることはありません。 + +Windows で `CODEX_CLI_PATH=codex` のような単純なコマンド名、リモートパス、デバイスパスが候補としてキャプチャされた場合は、`candidate_path_unavailable` を報告します。候補は取得されていますが、そのパスはこの検査の対象になりません。 ### `ocx config ...` diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 74f3b3151be..ee7604688bf 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -172,6 +172,8 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 カタログは、ローカル書き込みの前に拒否されます。認証は任意で、環境変数参照 (`--auth-env`) から のみ読み取られ、argv からは読み取られません。 +`HTTP_PROXY` または `http_proxy` が適用され、`NO_PROXY` または `no_proxy` に一致する除外設定がない場合、ループバック HTTP リクエストは認証ヘッダーの付与や送信より前に拒否されます。`ALL_PROXY`/`all_proxy`、または `HTTPS_PROXY`/`https_proxy` だけの設定では、この HTTP 制限は適用されず、HTTPS によるカタログ取得は引き続き許可されます。拒否メッセージにプロキシのアドレスや認証トークンは含まれません。 空でない `http_proxy` と `no_proxy` は、それぞれ `HTTP_PROXY` と `NO_PROXY` より優先されます。Bun に対応する除外ルールには、ホスト名、一致する `host:port`、`[::1]` のように角括弧で囲んだ IPv6 アドレス、または `*` を使い、URL、パス、`*.` 接頭辞は使わないでください。 + カタログとキャッシュは共有の Codex カタログロックの下で書き込まれ、失敗時は last-known-good の ファイルが保持されます。バイトが同一の場合は mtime を保持する no-op です。`--restart-codex`、 `--restart-app-server-only`、非推奨エイリアス `--restart-desktop-app` は、実際の書き込みの後に diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index cb4f5489e64..3f83e0aab90 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -250,6 +250,8 @@ ocx account main switch --yes [--json] ocx account main recover [--rollback --yes] [--json] ``` +`ocx account main reauth --device --no-wait --json` は成功時に単一の JSON オブジェクトを stdout に出力し、人向けの `follow up:` 行は出力しません。進行状況は、返された `flowId` を `ocx account main reauth status --flow --json` に指定して確認できます。 + 各変更コマンドは、実行中のプロキシが返す正規化済みの有効な `CODEX_HOME` を表示します。このパスは 呼び出し元の `CODEX_HOME` と異なる場合があり、JSON 対応コマンドは同じ値を `effectiveCodexHome` として返します。 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 0d049a01fc0..0d92b524e5c 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -387,4 +387,6 @@ opencodex가 managed [background service](/reference/cli/#ocx-service)로 실행 영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`를 반환할 수 있습니다. 이 이유로는 Codex 설정, 참조 프로필, 모델 카탈로그를 더 이상 거부하지 않습니다. `ocx sync`와 `ocx start`는 해당 파일과 `model_catalog_json`을 계속 쓰므로 Codex 모델 선택기에는 OpenCodex가 라우팅하는 모델이 모두 그대로 보입니다. 대화 기록의 프로바이더 재지정을 건너뛰는 것은 이 이유뿐이며, 페이지 분할 순번은 Codex 자체의 네이티브 기록 작성자가 할당하고 재시도해도 달라지지 않기 때문입니다. 읽을 수 없는 상태 데이터베이스, 식별자가 바뀐 대화 원본, 실행하지 못한 사전 검사처럼 다른 기록 사전 검사 이유는 나중에 성공할 수 있으므로 전환 전체를 거부하고 되돌립니다. 이 상태에서 OpenCodex는 페이지 분할 대화 원본이나 스레드 행을 수정하지 않습니다. 기존 대화는 이미 붙어 있는 프로바이더를 유지하고 이전되지 않으며, 새 대화는 평소처럼 프록시를 통해 라우팅됩니다. 재지정을 건너뛸 때 홈에 이미 있던 `[model_providers.opencodex]` 테이블은 폐기하지 않고 유지합니다. root-override(loopback) 형식에서도 같아서, 행이 `opencodex`로 표시된 대화는 아직 존재하는 프로바이더 id를 유지합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. CLI는 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`를 출력합니다. `ocx restore`와 Codex 설정 제거는 여전히 `history_paginated_requires_native_writer`로 거부됩니다. 스레드 행이 아직 참조하는데 `[model_providers.opencodex]` 정의를 걷어내면 그 대화를 해석할 수 없고, 복원 경로에는 호환 프로바이더 테이블을 남겨 둘 방법이 없습니다. 이미 페이지 분할된 홈은 지금은 제품으로 제거할 수 없습니다. 의도한 동작이 아니라 알려진 미해결 작업입니다. +루트 URL 재정의 방식으로 돌아갈 때 OpenCodex는 기록 사전 점검이 통과하더라도 기존 `[model_providers.opencodex]` 정의를 설정 적용 전에 유지합니다. 설정 적용 후나 백그라운드 기록 작업 시작 중에 Codex가 기록 형식을 전환해도 이전 `opencodex` 대화가 제공자를 계속 찾을 수 있습니다. 새 대화는 선택된 루트 제공자를 사용하며, 명시적 복원에는 기존의 별도 제거 검사가 적용됩니다. + 대화를 강제로 이전하려고 실행 중인 페이지 분할 대화 원본이나 스레드 행을 고치지 마세요. 복구 전에 해당 대화를 닫은 뒤, 개인 대화 내용을 올리지 말고 정확한 오류와 버전을 보고하세요. 백업이나 스크립트 성공만으로 표시 복구가 증명되지는 않으므로 Codex를 다시 열어 확인하세요. diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index b8d40874318..238127c1cee 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -66,7 +66,7 @@ alias는 클라이언트가 요청하는 공개 이름만 바꿉니다. 콤보 클라이언트가 콤보를 바꾼 뒤 공급자 접두사 없는 모델 이름으로 압축을 요청하면, opencodex는 같은 대화에서 가장 최근에 응답을 성공적으로 마친 콤보를 기억해 사용할 수 있습니다. 모델 이름이 완료된 응답과 일치하고, 현재 설정에 해당 콤보와 대상이 남아 있어야 합니다. 압축 요청도 일반 콤보 선택과 페일오버를 따릅니다. -명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하며 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다. +명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하고, 모델 이름 하나당 1 KiB·전체 64 KiB로 제한하며, 만료된 기록은 배경에서도 정리합니다. 모델 이름이 너무 커서 보관할 수 없는 응답은 이전 선택을 지우지 않고 그대로 둡니다. 기록은 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다. ## 전략 선택 diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 83aeaf2dd3f..64193b42328 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -167,10 +167,12 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. ### Reasoning effort -`gpt-5.6-sol`과 `claude-opus-5`는 네이티브 effort를 지원하며 요청 필드 이름이 다릅니다. -`low` / `medium` / `high` / `xhigh` / `max` 값은 각각 -`additionalModelRequestFields.reasoning.effort`와 `output_config.effort`로 전송됩니다. - +GPT-5.6 계열은 `additionalModelRequestFields.reasoning.effort`를, `claude-opus-5`는 +`additionalModelRequestFields.output_config.effort`를 사용합니다. `gpt-5.6-luna`와 +`gpt-5.6-terra`는 검증된 `low`, `medium`, `high`, `max`만 네이티브 필드로 전송합니다. +두 모델의 `xhigh`는 네이티브 동작이 검증되지 않아 기존의 제한된 thinking 지시문 방식을 유지합니다. +`gpt-5.6-sol`과 `claude-opus-5`의 기존 네이티브 단계(`low`, `medium`, `high`, `xhigh`, `max`)는 +바뀌지 않습니다. 다른 Kiro 모델의 effort는 에뮬레이션이며, 조절 항목이 있다고 네이티브 지원을 뜻하지는 않습니다. ## `cursor` diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index d95bf391adc..31548f2ad31 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -23,7 +23,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -34,23 +35,26 @@ src/ └── index.ts # public entry ``` -기존의 대형 진입 파일 세 개는 이제 호환성 facade입니다. `codex/catalog.ts`는 7개의 -`codex/catalog/*.ts` 모듈을, `server/management-api.ts`는 9개의 `server/management/*.ts` -모듈을, `server/responses.ts`는 5개의 `server/responses/*.ts` 모듈을 연결합니다. +기존의 대형 진입 파일들은 이제 호환성 facade입니다. `codex/catalog.ts`는 +`codex/catalog/*.ts` 모듈을, `server/management-api.ts`는 `server/management/*.ts` +모듈을, `server/responses.ts`는 `server/responses/*.ts` 모듈을, `bridge.ts`는 `bridge/*.ts` +모듈을 연결합니다. facade는 안정적인 import 경로일 뿐 구현이 아닙니다. 아래 각 단계는 +실제 코드를 소유한 모듈을 가리키며, Responses 표면의 전체 소유권 목록은 +`structure/transports/responses.md`에 있습니다. ## 요청 처리 흐름 -HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `server/responses.ts` facade와 +HTTP 경계는 `server/index/serve-options.ts`가 맡고, Responses 데이터 플레인은 `server/responses.ts` facade와 `server/responses/*.ts` 모듈로 넘깁니다. -1. `server/index.ts`에서 CORS와 API 인증을 확인하고, 종료 대기 중이면 새 요청을 거부한 뒤 요청 수명 +1. `server/index/serve-options.ts`에서 CORS와 API 인증을 확인하고, 종료 대기 중이면 새 요청을 거부한 뒤 요청 수명 주기를 기록합니다. 여기서 `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (Codex 내장 `image_gen` 도구용 — `server/images.ts`가 OpenAI 계열 업스트림으로 중계), `POST /v1/live` / `POST /v1/realtime/calls`(ChatGPT / Codex App 음성 및 OpenAI Realtime 호출 생성, `server/live.ts`가 중계)와 `/v1/live/{callId}` 사이드밴드 WebSocket, 그리고 `/v1/responses`의 선택적 WebSocket 업그레이드를 제공합니다. -2. `server/responses/core.ts`가 압축을 풀고 JSON을 읽습니다. 기억해 둔 `previous_response_id` 입력이 있으면 +2. `server/responses/request-prepare.ts`가 압축을 풀고 JSON을 읽습니다. 기억해 둔 `previous_response_id` 입력이 있으면 펼친 다음 `responses/parser.ts`로 넘깁니다. 3. `router.ts`가 일반 모델 id 또는 `provider/model` id를 해석합니다. 이어서 Codex 계정 affinity를 결정하고, 필요하면 프로바이더 OAuth를 갱신해 선택된 자격 증명을 route에 적용합니다. @@ -61,7 +65,7 @@ HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `se 나머지 변환형 어댑터는 업스트림 요청을 build/fetch/parse합니다. 6. 라우팅 모델이 호스티드 `web_search`를 요청하면 `web-search/`가 합성 함수를 노출합니다. 실제 검색은 ChatGPT 사이드카로 실행하고 결과를 라우팅 모델에 다시 넣으며, 설정된 횟수 안에서 반복합니다. -7. `bridge.ts`가 Responses SSE 또는 JSON을 만듭니다. `server/request-log.ts`와 `usage/`는 응답을 +7. `bridge/sse.ts` / `bridge/response-json.ts`가 Responses SSE 또는 JSON을 만듭니다. `server/request-log.ts`와 `usage/`는 응답을 건드리지 않은 채 종료 상태, 지연 시간, 프로바이더/모델, 최선 추정 토큰 사용량을 기록합니다. ## 파서 @@ -83,7 +87,7 @@ HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `se ## 브리지 -`bridge.ts`는 어댑터의 내부 `AdapterEvent` 스트림을 Codex가 이해하는 Responses SSE로 다시 +`bridge/sse.ts`는 어댑터의 내부 `AdapterEvent` 스트림을 Codex가 이해하는 Responses SSE로 다시 변환합니다: | AdapterEvent | Responses SSE emitted | @@ -114,7 +118,7 @@ Responses 항목 타입으로 구분됩니다 — 따라서 MCP 네임스페이 ## 전송과 compaction -`server/index.ts`는 기본적으로 `/v1/responses`를 HTTP/SSE로 제공합니다. `websockets`가 `false`인 +`server/index/serve-options.ts`는 기본적으로 `/v1/responses`를 HTTP/SSE로 제공합니다. `websockets`가 `false`인 상태에서 Codex가 Responses WebSocket 업그레이드를 시도하면 opencodex는 `426 upgrade_required`를 반환하고, Codex는 해당 세션에서 HTTP로 폴백합니다. `"websockets": true`가 설정되면 같은 엔드포인트가 업그레이드를 받아들이고 WebSocket 브리지를 사용합니다. @@ -138,7 +142,7 @@ Lite 정책을 유지합니다. 네이티브 메타데이터 형식이 잘못된 Codex 컨텍스트 compaction은 라우팅된 모델에서도 동작합니다. `server/responses/compact.ts`는 `POST /v1/responses/compact`를 내부 라우팅 요약 턴으로 처리해 압축된 히스토리를 반환합니다. -`responses/parser.ts`와 `bridge.ts`는 remote compaction v2의 `compaction_trigger` 턴을 처리해 +`responses/parser.ts`와 `bridge/sse.ts`는 remote compaction v2의 `compaction_trigger` 턴을 처리해 합성 `compaction` 출력 항목을 정확히 하나 내보냅니다. ## 캐싱과 카탈로그 diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 3b384c20f05..f75de642c55 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -17,7 +17,7 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. -`ocx system codex-cli-update check`는 실행 중인 프록시가 없어도 되며 패키지 레지스트리를 조회하지 않습니다. 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 메타데이터를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 소프트웨어를 설치하거나 복구하지 않고, Codex나 npm을 실행하지 않으며, 실행 중인 프로세스를 제어하거나 설정 또는 캐시 상태를 쓰지 않습니다. +`ocx system codex-cli-update check`는 실행 중인 프록시가 없어도 되며 패키지 레지스트리를 조회하지 않습니다. 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 메타데이터를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 POSIX에서는 `candidate_unavailable`, Windows에서는 `windows_inspection_deferred`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 소프트웨어를 설치하거나 복구하지 않고, Codex나 npm을 실행하지 않으며, 실행 중인 프로세스를 제어하거나 설정 또는 캐시 상태를 쓰지 않습니다. 뜻이 분명하면 `list`나 `status`가 기본입니다. 구조화된 스냅샷은 `--json`을, 스트리밍 요청 로그 피드는 `ocx observe logs --follow --jsonl`을 사용합니다. 테마, 언어, 내비게이션처럼 순수하게 시각적인 브라우저 상태에는 CLI 대응이 없습니다. Cloudflare Tunnel 설정은 이 명령 집합 밖입니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index be2834963ab..b64ca7fc3ba 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -226,7 +226,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check`는 패키지 레지스트리를 조회하지 않고, 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 정보를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 Codex나 패키지 관리자를 실행하거나 shim을 복구하지 않고, 설정 또는 캐시 상태를 쓰거나 프로세스를 중지하거나 어떤 것도 설치하지 않습니다. 앱에 포함된 후보, 인식된 버전 관리자의 후보, 검증되지 않은 독립 실행형 후보, shim 상태가 모호한 후보는 관리 대상이 아니거나 알 수 없는 것으로 보고되며, 관리 대상으로 분류되지 않습니다. +`check`는 패키지 레지스트리를 조회하지 않고, 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 정보를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 POSIX에서는 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 조각은 저장된 선택 상태를 전혀 읽지 않으므로, 환경 후보가 캡처되지 않은 Windows 실행은 `candidate_unavailable`이 아니라 `windows_inspection_deferred`를 보고합니다. 명령이 Codex CLI 설치 여부를 관측할 수 없으므로, 후보가 없다고 단정하는 대신 검사가 연기되었음을 보고합니다. 이 명령은 Codex나 패키지 관리자를 실행하거나 shim을 복구하지 않고, 설정 또는 캐시 상태를 쓰거나 프로세스를 중지하거나 어떤 것도 설치하지 않습니다. 앱에 포함된 후보, 인식된 버전 관리자의 후보, 검증되지 않은 독립 실행형 후보, shim 상태가 모호한 후보는 관리 대상이 아니거나 알 수 없는 것으로 보고되며, 관리 대상으로 분류되지 않습니다. + +Windows에서 `CODEX_CLI_PATH=codex` 같은 단순 명령 이름이나 원격 경로·장치 경로가 후보로 캡처되면 `candidate_path_unavailable`을 보고합니다. 후보는 캡처됐지만 해당 경로가 이 검사 대상에 적합하지 않은 경우입니다. ### `ocx config ...` diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 66ab5f98295..2cac37ed244 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -256,6 +256,8 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 자격증명, 쿼리, 프래그먼트, 리다이렉트, 크기를 넘는 응답, 잘못된 카탈로그는 로컬에 쓰기 전에 거절합니다. 인증은 선택이며 환경변수 이름(`--auth-env`)으로만 읽고 argv로는 받지 않습니다. +`HTTP_PROXY` 또는 `http_proxy`가 적용되고 `NO_PROXY` 또는 `no_proxy`에 일치하는 우회 항목이 없으면 루프백 HTTP 요청은 인증 헤더를 붙이거나 요청을 보내기 전에 거부됩니다. `ALL_PROXY`/`all_proxy` 또는 `HTTPS_PROXY`/`https_proxy`만 설정한 경우에는 이 HTTP 제한에 해당하지 않으며, HTTPS 카탈로그 취득은 계속 허용됩니다. 거부 메시지에는 프록시 주소나 인증 토큰이 포함되지 않습니다. 값이 비어 있지 않은 `http_proxy`와 `no_proxy`는 각각 `HTTP_PROXY`와 `NO_PROXY`보다 우선합니다. Bun과 호환되는 우회 규칙에는 호스트 이름, 일치하는 `host:port`, `[::1]`처럼 대괄호로 감싼 IPv6 주소 또는 `*`를 사용하고, URL·경로·`*.` 접두사는 사용하지 마세요. + 카탈로그와 캐시는 공유 Codex 카탈로그 잠금 아래에서 쓰고, 실패하면 직전까지 정상이던 파일을 그대로 둡니다. 바이트가 같으면 mtime까지 건드리지 않는 no-op입니다. `--restart-codex`, `--restart-app-server-only`, 폐기 예정 별칭 `--restart-desktop-app`은 실제로 쓴 뒤에만 diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 3d637dbd924..bc618edfbea 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -315,6 +315,8 @@ ocx account main switch --yes [--json] ocx account main recover [--rollback --yes] [--json] ``` +`ocx account main reauth --device --no-wait --json`은 성공 시 stdout에 JSON 객체 하나만 출력하며, 사람이 읽는 `follow up:` 안내 줄은 출력하지 않습니다. 반환된 `flowId`를 `ocx account main reauth status --flow --json`에 지정하면 진행 상태를 확인할 수 있습니다. + 각 변경 명령은 실행 중인 프록시가 반환한 정규화된 유효 `CODEX_HOME`을 표시합니다. 이 경로는 호출자의 `CODEX_HOME`과 다를 수 있으며, JSON을 지원하는 명령은 같은 값을 `effectiveCodexHome`으로 반환합니다. diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index e4fe0befb98..31eb642e79b 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -271,8 +271,9 @@ call creation과 sideband join은 같은 OpenAI 계정으로 이루어져야 하 거부합니다(`404`). 두 요청 모두 Codex의 `session-id`와 `thread-id` 헤더를 실어 보냅니다. Pool 모드는 계정 선택을 그 쌍에 묶어 두므로(프로세스 로컬) 프록시에 도착한 join은 통화를 만든 계정을 그대로 쓰고, Direct 모드는 두 요청 모두 호출자의 현재 bearer를 전달합니다. 릴레이되는 클라이언트 헤더는 정확히 -`openai-alpha`, `x-session-id`, `session-id`, `thread-id`, `originator`, `x-oai-attestation` -(`src/server/live.ts`의 `LIVE_CLIENT_PROTOCOL_HEADERS`)이며, `Authorization`과 ChatGPT 계정 id는 +`openai-alpha`, `x-session-id`, `session-id`, `thread-id`, `originator`, `x-oai-attestation`, +`x-codex-turn-metadata`(`src/server/live.ts`의 `LIVE_CLIENT_PROTOCOL_HEADERS`)이며, 각 헤더는 +호출자가 보낸 경우에만 전달되고 프록시가 만들어 내지 않습니다. `Authorization`과 ChatGPT 계정 id는 ChatGPT 경로에서 프록시가 소유합니다(Pool은 저장된 계정으로 교체, Direct는 검증된 호출자 bearer를 전달). API 키 프로바이더는 자체 bearer를 씁니다. Codex가 join을 프록시로 보내는 것은 `experimental_realtime_ws_base_url`이 프록시를 가리킬 때뿐이며, `ocx start`가 이 키를 diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 715555f847c..4da02652ae7 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -123,6 +123,12 @@ body and response, with narrow compatibility rewrites for routed gateways. `forward` uses configured static headers without relaying caller authorization; `key` uses the configured provider key. +The adapter preserves the incoming client's `User-Agent` as a fallback in both auth modes because +some Responses-compatible providers use the Codex client fingerprint for compatibility behavior. +An explicitly configured provider `User-Agent` remains authoritative regardless of header casing; +if the caller sends none, OpenCodex does not invent one. No other caller header is widened by this +exception. + Adapter selection does not select the upstream transport. Eligible requests can use the [upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses Bun's @@ -364,13 +370,13 @@ important than cosmetic de-duplication. Tool-free requests retain normal text co ### Reasoning effort -`gpt-5.6-sol` and `claude-opus-5` have verified native effort support, and each model family names -the request field differently. A selected `low`, `medium`, `high`, `xhigh`, or `max` value is sent -as `additionalModelRequestFields.reasoning.effort` for `gpt-5.6-sol` and as -`additionalModelRequestFields.output_config.effort` for `claude-opus-5`. Other Kiro models currently -use emulated reasoning: opencodex converts the selected level into bounded thinking instructions in -the user content because their native effort field has not been verified. Do not interpret an -advertised effort control on those models as proof of upstream-native reasoning support. +The GPT-5.6 family uses `additionalModelRequestFields.reasoning.effort`; `claude-opus-5` +uses `additionalModelRequestFields.output_config.effort`. For `gpt-5.6-luna` and +`gpt-5.6-terra`, only `low`, `medium`, `high`, and `max` use the verified native path. +Their `xhigh` selection retains the previous bounded thinking instructions in user content +because that native rung has not been verified. `gpt-5.6-sol` and `claude-opus-5` keep +their existing native `low`, `medium`, `high`, `xhigh`, and `max` behavior. Other Kiro +models use emulated reasoning; an advertised effort control is not proof of native support. ## `cursor` diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index a9bdf48978f..c04f647b724 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -23,7 +23,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -34,17 +35,19 @@ src/ └── index.ts # public entry ``` -Three formerly large entry files now preserve compatibility as facades: `codex/catalog.ts` exports -the seven focused `codex/catalog/*.ts` modules, `server/management-api.ts` dispatches to the nine -`server/management/*.ts` modules, and `server/responses.ts` exports the five -`server/responses/*.ts` modules. +Several formerly large entry files now preserve compatibility as facades: `codex/catalog.ts` exports +its focused `codex/catalog/*.ts` modules, `server/management-api.ts` dispatches to +`server/management/*.ts`, `server/responses.ts` exports `server/responses/*.ts`, and `bridge.ts` +re-exports `bridge/*.ts`. A facade is the stable import path, not the implementation: each step +below names the module that owns the code, and `structure/transports/responses.md` carries the +full owner inventory for the Responses surface. ## Request flow -`server/index.ts` owns the HTTP boundary and delegates the Responses data plane to +`server/index/serve-options.ts` owns the HTTP boundary and delegates the Responses data plane to the `server/responses.ts` facade and its `server/responses/*.ts` modules: -1. `server/index.ts` applies CORS and API authentication, rejects new work while draining, and +1. `server/index/serve-options.ts` applies CORS and API authentication, rejects new work while draining, and records request lifecycle metadata. It serves `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (relayed to an OpenAI-family upstream by `server/images.ts` for codex's built-in `image_gen` @@ -52,7 +55,7 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules: Realtime call-create, relayed by `server/live.ts`), sideband WebSocket joins on `/v1/live/{callId}` (and `/v1/realtime?call_id=`), and the optional WebSocket upgrade on `/v1/responses`. -2. `server/responses/core.ts` decompresses and parses JSON, expands locally remembered +2. `server/responses/request-prepare.ts` decompresses and parses JSON, expands locally remembered `previous_response_id` input when available, then calls `responses/parser.ts`. 3. `router.ts` resolves a bare or `provider/model` id. The server then resolves Codex account affinity, refreshes provider OAuth when needed, and applies the selected credential to the route. @@ -65,7 +68,7 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules: executes the real search through the configured backend (the OpenAI/ChatGPT sidecar or Anthropic), feeds results back to the routed model, and repeats within the configured loop limit. This loop supports only the standard HTTP path; adapters that implement `runTurn`, such as Cursor, bypass it. -7. `bridge.ts` produces Responses SSE or JSON. `server/request-log.ts` and `usage/` collect terminal +7. `bridge/sse.ts` / `bridge/response-json.ts` produces Responses SSE or JSON. `server/request-log.ts` and `usage/` collect terminal status, latency, provider/model labels, and best-effort token usage without changing the response. ## The parser @@ -86,7 +89,7 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules: ## The bridge -`bridge.ts` turns the adapter's internal `AdapterEvent` stream back into Responses SSE that Codex +`bridge/sse.ts` turns the adapter's internal `AdapterEvent` stream back into Responses SSE that Codex understands: | AdapterEvent | Responses SSE emitted | @@ -138,7 +141,7 @@ diagnostics. ## Transport and compaction -`server/index.ts` serves HTTP/SSE on `/v1/responses` by default. If Codex attempts a Responses +`server/index/serve-options.ts` serves HTTP/SSE on `/v1/responses` by default. If Codex attempts a Responses WebSocket upgrade while `websockets` is `false`, opencodex returns `426 upgrade_required`; Codex then falls back to HTTP for that session. When `"websockets": true` is set, the same endpoint accepts the upgrade and uses the WebSocket bridge. @@ -174,7 +177,7 @@ retry after compaction. Non-streaming API callers continue to receive the provid Codex context compaction works for routed models. `server/responses/compact.ts` handles `POST /v1/responses/compact` by running an internal routed summarization turn and returning compacted -history, while `responses/parser.ts` and `bridge.ts` handle remote compaction v2 +history, while `responses/parser.ts` and `bridge/sse.ts` handle remote compaction v2 `compaction_trigger` turns by emitting exactly one synthetic `compaction` output item. ## Caching & the catalog diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index d91e6c18651..5497199acac 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -56,7 +56,7 @@ remain report-only (`managed: false`, normally `selection_unattested`) and `sele The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report -`candidate_unavailable`. On Windows this first slice performs no candidate or configuration filesystem I/O: +`candidate_unavailable` on POSIX or `windows_inspection_deferred` on Windows. On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; every other Windows candidate fails closed. The command does not install or repair software, execute Codex or npm, control a running process, or write configuration/cache state. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 51ada47a5b3..dadd0c55b0d 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -369,13 +369,18 @@ environment and persisted candidates remain report-only (`managed: false`, norma `selectionAttested` remains `false`. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report -`candidate_unavailable`. On Windows this first slice performs no candidate or configuration filesystem I/O: +`candidate_unavailable` on POSIX. On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; -every other Windows candidate fails closed. The command does not execute Codex or a package manager, repair a shim, +every other Windows candidate fails closed. Because that slice never consults persisted state, a Windows run +with no captured environment candidate reports `windows_inspection_deferred` rather than `candidate_unavailable`: +the command cannot observe whether a Codex CLI is installed, so it reports the deferral instead of asserting +that no candidate exists. The command does not execute Codex or a package manager, repair a shim, write configuration or cache state, stop a process, or install anything. App-bundled, recognized version-manager, unverified standalone, and ambiguous shim states are reported as unmanaged or unknown and are never classified as managed. +On Windows, a captured bare command such as `CODEX_CLI_PATH=codex`, a remote path, or a device path reports `candidate_path_unavailable` instead. Those cases have a captured candidate; its path is not eligible for this inspection. + ### `ocx config ...` Inspect and safely modify validated OpenCodex configuration. `show` and `get` mask secrets. Import diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index dd1f07c7118..32ff382d697 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -304,7 +304,11 @@ before rebuilding the cache. It works even when the local Codex integration desi The URL must be HTTPS; loopback HTTP is accepted for local testing. Embedded URL credentials, queries, fragments, redirects, oversized responses, malformed JSON, duplicate or unsafe slugs, and -unknown `input_modalities` are refused before any local write. Authentication is optional and is +unknown `input_modalities` are refused before any local write. + +Loopback HTTP requests are refused before authentication headers are attached or any request is sent when `HTTP_PROXY` or `http_proxy` applies without a matching `NO_PROXY` or `no_proxy` bypass. `ALL_PROXY`/`all_proxy` and settings limited to `HTTPS_PROXY`/`https_proxy` do not trigger this HTTP restriction; HTTPS catalog acquisition remains allowed. The refusal message includes neither the proxy address nor the authentication token. Nonempty `http_proxy` and `no_proxy` take precedence over `HTTP_PROXY` and `NO_PROXY`, respectively. For Bun-compatible bypass rules, use hostnames, matching `host:port` entries, bracketed IPv6 addresses such as `[::1]`, or `*`; do not use URLs, paths, or `*.` prefixes. + +Authentication is optional and is read only by environment-variable reference: ```bash diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 9033cec5f15..d75bc6a3b9b 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -497,6 +497,8 @@ ocx account main switch --yes [--json] ocx account main recover [--rollback --yes] [--json] ``` +`ocx account main reauth --device --no-wait --json` writes one JSON object to stdout on success, without the human-readable `follow up:` line. Use its `flowId` with `ocx account main reauth status --flow --json` to check progress. + Each mutating command reports the canonical effective `CODEX_HOME` returned by the running proxy. This path can differ from the caller's `CODEX_HOME`; commands that support JSON expose the same value as `effectiveCodexHome`. diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 2934c337a62..d830faafae1 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -90,7 +90,8 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, | `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. Applies only to round-robin. | | `cooldownMs?` | `number` | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Range 1–600000. When set, applies whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. Upstream signals take precedence and all cooldowns are capped at 10 minutes. | | `waitForCooldownMs?` | `number` | `0` | Maximum wait for the earliest eligible cooling target on each selection attempt before returning `combo_unavailable`. Range 0–600000; an abort cancels the wait. | -| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | `defaultEffort` fills an absent `reasoning.effort` when the combo has a non-null default and the selected target has a known, nonempty supported ladder. If the target supports the configured value, it is retained; otherwise the highest supported rung at or below it is used, or the lowest supported rung when none is lower. Unknown or empty ladders omit the default. | +| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | `defaultEffort` fills an absent `reasoning.effort` in fallback mode, or overrides valid caller effort in explicit force mode when the combo has a non-null default and the selected target has a known, nonempty supported ladder. If the target supports the configured value, it is retained; otherwise the highest supported rung at or below it is used, or the lowest supported rung when none is lower. Unknown or empty ladders omit the default. | +| `defaultEffortMode?` | `"fallback" \| "force"` | `"fallback"` | Preserves caller precedence by default. Explicit force requires a valid non-null default, respects target capability and can increase cost and latency. `reasoningEffortMode` remains independent. | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"` intersects all known target ladders, including empty ones; `"adaptive"` excludes empty ladders. Unknown ladders are catalog wildcards in both modes. At dispatch, explicit empty ladders remove effort/thinking controls in both modes; unknown ladders do so only in adaptive. `reasoning.summary` is preserved. Known nonempty targets retain their effort resolution, and target selection/order is unchanged. | | `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"auto"` publishes image only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). | | `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 3fbf619df47..4d1fcd20da9 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -50,6 +50,16 @@ frame may already be executing upstream, so the client applies its own retry pol it would when connected to the backend directly. Once the response has started, a later drop surfaces inside the stream as before. `stallTimeoutSec` is unrelated to this window. +An ordinary HTTP send has a third case. When the connection dies before any response header +arrives, the proxy cannot tell whether the model already processed the request, so it refuses +to send it again and answers HTTP 429 with `upstream_reset_replay_refused`. The status is +deliberate: a 5xx here is an instruction to most clients, including Codex, to send the whole +turn again, which is the duplicate the refusal exists to prevent. No `Retry-After` is +attached, and the proxy performs no key rotation, account failover or same-target replay on +it, nor does it record the refusal as rate-limit or quota evidence against the credential it +was holding. Tool-call side requests such as vision and web search are replayed normally, because +repeating them cannot duplicate a turn. + `noProxy` accepts either a comma-separated string or an array. Both forms add entries without replacing an inherited `NO_PROXY`: diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index b973add5d96..d03a78f5f42 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -246,6 +246,22 @@ final provider. Custom destinations and historic rows omit the field; consumers infer subscription usage from the current configuration, model name, or inbound API key. The log reports usage, not subscription invoice amounts. +API-key attempts also record `accountLogLabel` as `k` followed by 32 lowercase hex digits. +The label is the first 128 bits of SHA-256 over +`JSON.stringify(["ocx-key-account-v1", providerName, entryId ?? null, reference])`. +The reference is the configured key value captured for the physical request, before environment +or keychain resolution. Raw keys, references, and pool IDs are not written to the label field. +A consumer can derive the same label from its local configuration without resolving secrets. +Changing a literal key or reference changes the label; replacing the secret behind an unchanged +reference keeps the same logical account. Older unlabeled records cannot be attributed reliably. + +Key selection is recorded after queued requests have been rebuilt for the current selection. +When a retry changes keys, `attempts` retains a separate record for the preceding key, including +reported usage from failed responses. Missing usage remains unreported. Routed adapter terminals +are observed before image/search loops or continuation guards combine their usage. Consumers +sum the flat attempts by provider/account and do not add the parent combo total again. These +records identify usage; provider quota percentages remain separate upstream observations. + `GET /api/usage` reads `~/.opencodex/usage.jsonl` from the beginning through the current ledger snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather than every normalized request row. Later refreshes validate the previous line boundary and fold only diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index e2e81458451..f10e232f571 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -563,7 +563,9 @@ upstream (`404`). Both legs carry Codex's `session-id` and `thread-id` headers; account choice is bound to that pair (process-local), so a join that reaches the proxy reuses the account that created the call, while Direct mode forwards the caller's current bearer on both legs. The relayed client headers are exactly `openai-alpha`, `x-session-id`, `session-id`, `thread-id`, -`originator`, and `x-oai-attestation` (`LIVE_CLIENT_PROTOCOL_HEADERS` in `src/server/live.ts`); +`originator`, `x-oai-attestation`, and `x-codex-turn-metadata` +(`LIVE_CLIENT_PROTOCOL_HEADERS` in `src/server/live.ts`); each is relayed only when the caller +sent it, and none is invented. `Authorization` and the ChatGPT account id are proxy-owned on ChatGPT-backed routes (Pool replaces them with the stored account, Direct forwards the validated caller bearer) and an API-key provider gets its own bearer. Codex only sends the join to the proxy when `experimental_realtime_ws_base_url` 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 1cca5f59fd4..1394091fe90 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -415,4 +415,6 @@ ocx restore back # point plain Codex at the running proxy again Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. По этой причине больше не отклоняются конфигурация Codex, опорный профиль и каталог моделей. `ocx sync` и `ocx start` по-прежнему записывают эти файлы и задают `model_catalog_json`, поэтому выбор модели Codex продолжает показывать все модели, маршрутизируемые через OpenCodex. Переразметку истории разговоров останавливает только эта причина: порядковые номера постраничной истории выделяет собственный процесс записи Codex, и повторная попытка этого не меняет. Любая другая причина предварительной проверки истории — нечитаемая база состояния, история со сменившейся идентификацией или проверка, которую не удалось запустить, — по-прежнему отклоняет весь переход и откатывает его, потому что такие случаи могут пройти позже. В этом состоянии OpenCodex не изменяет постраничные файлы истории и строки тредов. Существующие разговоры сохраняют уже назначенного провайдера и не мигрируют; новые разговоры идут через прокси как обычно. Когда переразметка останавливается, таблица `[model_providers.opencodex]`, уже бывшая в домашнем каталоге, сохраняется, а не снимается, в том числе в форме root-override (loopback), чтобы разговоры со строками, помеченными `opencodex`, сохраняли существующий идентификатор провайдера. CLI выводит `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` и удаление конфигурации Codex по-прежнему отказывают по `history_paginated_requires_native_writer`. Удаление определения `[model_providers.opencodex]`, пока строки тредов на него ссылаются, сделало бы эти разговоры неразрешимыми, а путь восстановления не умеет оставлять таблицу совместимости провайдера. Домашний каталог, уже переведённый на постраничную историю, сейчас нельзя удалить средствами продукта; это известная открытая задача, а не задуманное поведение. +При возврате к режиму переопределения корневого URL OpenCodex сохраняет существующее определение `[model_providers.opencodex]` до фиксации конфигурации, даже если предварительная проверка истории успешна. Поэтому старые разговоры `opencodex` сохраняют доступ к своему провайдеру, если Codex преобразует историю после фиксации или во время запуска фоновой обработки. Новые разговоры используют выбранный корневой провайдер; явное восстановление по-прежнему выполняет отдельные проверки удаления. + Не переписывайте активную постраничную историю или строку треда, чтобы самостоятельно перенести разговоры. Закройте разговор перед восстановлением и сообщите точную ошибку и версии без публикации личной истории. Наличие резервной копии или успешный скрипт не доказывает восстановление отображения: проверьте разговор после повторного открытия Codex. diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index d2d1f53de9a..1d81b039e23 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -189,10 +189,12 @@ incomplete. `TOOL_USE` без фактического вызова инстру ### Reasoning effort -`gpt-5.6-sol` и `claude-opus-5` поддерживают нативный effort, но называют поле запроса по-разному. -Значения `low` / `medium` / `high` / `xhigh` / `max` отправляются как -`additionalModelRequestFields.reasoning.effort` и `output_config.effort` соответственно. - +Семейство GPT-5.6 использует `additionalModelRequestFields.reasoning.effort`, а `claude-opus-5` — +`additionalModelRequestFields.output_config.effort`. Для `gpt-5.6-luna` и `gpt-5.6-terra` нативный +путь проверен только для `low`, `medium`, `high` и `max`. Их `xhigh` сохраняет прежнюю эмуляцию +через ограниченные инструкции thinking, поскольку нативный уровень не проверен. +Существующие нативные уровни `gpt-5.6-sol` и `claude-opus-5` (`low`, `medium`, `high`, `xhigh`, `max`) +не меняются. Остальные модели Kiro используют эмуляцию; наличие настройки effort не доказывает нативную поддержку. ## `cursor` diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index cd3f776efbb..43db27e8fb0 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -24,7 +24,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -35,17 +36,20 @@ src/ └── index.ts # public entry ``` -Три прежних крупных входных файла теперь служат фасадами совместимости: `codex/catalog.ts` -экспортирует семь модулей `codex/catalog/*.ts`, `server/management-api.ts` направляет запросы в -девять модулей `server/management/*.ts`, а `server/responses.ts` экспортирует пять модулей -`server/responses/*.ts`. +Прежние крупные входные файлы теперь служат фасадами совместимости: `codex/catalog.ts` +экспортирует модули `codex/catalog/*.ts`, `server/management-api.ts` направляет запросы в +модули `server/management/*.ts`, `server/responses.ts` экспортирует модули +`server/responses/*.ts`, а `bridge.ts` реэкспортирует модули `bridge/*.ts`. Фасад — это +стабильный путь импорта, а не реализация: каждый шаг ниже называет модуль, которому +принадлежит код, а полный перечень владельцев поверхности Responses находится в +`structure/transports/responses.md`. ## Поток запроса -`server/index.ts` владеет HTTP-границей и делегирует плоскость данных Responses в +`server/index/serve-options.ts` владеет HTTP-границей и делегирует плоскость данных Responses в фасад `server/responses.ts` и его модули `server/responses/*.ts`: -1. `server/index.ts` применяет CORS и аутентификацию API, отклоняет новую работу во время +1. `server/index/serve-options.ts` применяет CORS и аутентификацию API, отклоняет новую работу во время завершения (drain) и записывает метаданные жизненного цикла запроса. Он обслуживает `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` @@ -54,7 +58,7 @@ src/ (создание голосового/Realtime-вызова ChatGPT / Codex App, ретранслируется `server/live.ts`), sideband WebSocket на `/v1/live/{callId}`, а также необязательный WebSocket-апгрейд на `/v1/responses`. -2. `server/responses/core.ts` распаковывает и парсит JSON, разворачивает локально запомненный вход +2. `server/responses/request-prepare.ts` распаковывает и парсит JSON, разворачивает локально запомненный вход `previous_response_id`, когда он доступен, затем вызывает `responses/parser.ts`. 3. `router.ts` разрешает «голый» id или id вида `provider/model`. Затем сервер определяет привязку (affinity) аккаунта Codex, при необходимости обновляет OAuth провайдера и применяет @@ -70,7 +74,7 @@ src/ предоставляет синтетическую функцию, выполняет настоящий поиск через сайдкар ChatGPT, возвращает результаты маршрутизируемой модели и повторяет это в пределах настроенного лимита цикла. -7. `bridge.ts` формирует Responses SSE или JSON. `server/request-log.ts` и `usage/` собирают +7. `bridge/sse.ts` / `bridge/response-json.ts` формирует Responses SSE или JSON. `server/request-log.ts` и `usage/` собирают итоговый статус, задержку, метки провайдера/модели и оценку использования токенов, не изменяя ответ. @@ -95,7 +99,7 @@ src/ ## Мост -`bridge.ts` превращает поток внутренних событий `AdapterEvent` адаптера обратно в Responses SSE, +`bridge/sse.ts` превращает поток внутренних событий `AdapterEvent` адаптера обратно в Responses SSE, понятный Codex: | AdapterEvent | Responses SSE emitted | @@ -146,7 +150,7 @@ loopback; настроенные записи `corsAllowOrigins` расширя ## Транспорт и compaction -`server/index.ts` по умолчанию обслуживает HTTP/SSE на `/v1/responses`. Если Codex пытается +`server/index/serve-options.ts` по умолчанию обслуживает HTTP/SSE на `/v1/responses`. Если Codex пытается выполнить WebSocket-апгрейд Responses, пока `websockets` равно `false`, opencodex возвращает `426 upgrade_required`; Codex тогда откатывается на HTTP для этой сессии. Когда установлено `"websockets": true`, та же конечная точка принимает апгрейд и использует WebSocket-мост. @@ -162,7 +166,7 @@ loopback; настроенные записи `corsAllowOrigins` расширя Compaction контекста Codex работает для маршрутизируемых моделей. `server/responses/compact.ts` обрабатывает `POST /v1/responses/compact`, выполняя внутренний маршрутизируемый ход суммаризации -и возвращая сжатую историю, а `responses/parser.ts` и `bridge.ts` обрабатывают ходы +и возвращая сжатую историю, а `responses/parser.ts` и `bridge/sse.ts` обрабатывают ходы `compaction_trigger` из remote compaction v2, генерируя ровно один синтетический выходной элемент `compaction`. diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index e25eff1afd7..0901634ee23 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -31,7 +31,7 @@ runtime port и проверку identity, а не поддерживая вто явно документированные как offline-операции с конфигурацией, вместо этого могут валидировать и редактировать файл конфигурации без живого прокси. -`ocx system codex-cli-update check` не требует работающего прокси и не обращается к реестру пакетов. Команда в строго ограниченном объёме проверяет метаданные происхождения настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable`. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не устанавливает и не восстанавливает ПО, не запускает Codex или npm, не управляет работающими процессами и ничего не записывает в конфигурацию или кеш. +`ocx system codex-cli-update check` не требует работающего прокси и не обращается к реестру пакетов. Команда в строго ограниченном объёме проверяет метаданные происхождения настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable` в POSIX или `windows_inspection_deferred` в Windows. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не устанавливает и не восстанавливает ПО, не запускает Codex или npm, не управляет работающими процессами и ничего не записывает в конфигурацию или кеш. Там, где это недвусмысленно, `list` или `status` являются действием по умолчанию. Для структурированных снимков используйте `--json`, а для потокового лога запросов — diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index f2175ec154d..0b81ba9d590 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -253,7 +253,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` не обращается к реестру пакетов и в строго ограниченном объёме проверяет данные о происхождении настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable`. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не запускает Codex или менеджер пакетов, не восстанавливает shim, ничего не записывает в конфигурацию или кеш, не останавливает процессы и ничего не устанавливает. Кандидаты, входящие в комплект приложения, найденные в распознанных путях менеджеров версий, являющиеся непроверенными автономными установками или имеющие неоднозначное состояние shim, отображаются как `unmanaged` или `unknown` и никогда не классифицируются как `managed`. +`check` не обращается к реестру пакетов и в строго ограниченном объёме проверяет данные о происхождении настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable` в POSIX-системах. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Поскольку этот этап вообще не читает сохранённое состояние выбора, запуск в Windows без захваченного кандидата из окружения возвращает `windows_inspection_deferred`, а не `candidate_unavailable`: команда не может определить, установлен ли Codex CLI, поэтому сообщает об отложенной проверке, а не утверждает, что кандидата нет. Команда не запускает Codex или менеджер пакетов, не восстанавливает shim, ничего не записывает в конфигурацию или кеш, не останавливает процессы и ничего не устанавливает. Кандидаты, входящие в комплект приложения, найденные в распознанных путях менеджеров версий, являющиеся непроверенными автономными установками или имеющие неоднозначное состояние shim, отображаются как `unmanaged` или `unknown` и никогда не классифицируются как `managed`. + +В Windows захваченная команда без полного пути, например `CODEX_CLI_PATH=codex`, удалённый путь или путь устройства возвращает `candidate_path_unavailable`. Кандидат захвачен, но его путь не подходит для этой проверки. ### `ocx config ...` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 04315f0e2a4..4de2c646247 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -253,6 +253,8 @@ loopback. Учётные данные в URL, query, фрагменты, ред каталоги отклоняются до любой локальной записи. Аутентификация необязательна и читается только по имени переменной окружения (`--auth-env`), но не из argv. +HTTP-запросы к loopback отклоняются до добавления заголовков аутентификации и отправки запроса, если применяется `HTTP_PROXY` или `http_proxy`, а в `NO_PROXY` или `no_proxy` нет подходящего исключения. `ALL_PROXY`/`all_proxy` и настройки только `HTTPS_PROXY`/`https_proxy` не вызывают это ограничение для HTTP; получение каталогов по HTTPS остаётся разрешённым. Сообщение об отказе не содержит адрес прокси или токен аутентификации. Непустые значения `http_proxy` и `no_proxy` имеют приоритет над `HTTP_PROXY` и `NO_PROXY` соответственно. Для совместимых с Bun правил обхода прокси используйте имена хостов, совпадающие записи `host:port`, IPv6-адреса в квадратных скобках, например `[::1]`, или `*`; не используйте URL, пути или префикс `*.`. + Каталог и кэш пишутся под общей блокировкой каталога Codex; при сбое сохраняются last-known-good файлы. Идентичные байты — это no-op, сохраняющий mtime. `--restart-codex`, `--restart-app-server-only` и устаревший alias `--restart-desktop-app` здесь означают то же, что diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 05bce2e1e43..8b92b55ccb5 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -309,6 +309,8 @@ ocx account main switch --yes [--json] ocx account main recover [--rollback --yes] [--json] ``` +При успехе `ocx account main reauth --device --no-wait --json` выводит в stdout один объект JSON без строки `follow up:`, предназначенной для чтения человеком. Чтобы проверить ход процесса, передайте полученный `flowId` в `ocx account main reauth status --flow --json`. + Каждая изменяющая команда показывает канонический эффективный `CODEX_HOME`, возвращенный работающим прокси. Этот путь может отличаться от `CODEX_HOME` вызывающего процесса; команды с поддержкой JSON возвращают то же значение в `effectiveCodexHome`. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 510a7818a7a..034f7505b55 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -189,9 +189,11 @@ alternatif bir Desktop kullanıcı verisi kökü için `CLAUDE_USER_DATA_DIR` değerini ayarlayın. Eski `Claude-3p` dizini otomatik olarak okunmaz veya silinmez. -Anthropic harici rotalar, `claude-opus-4-8-2026MMDD` gibi kararlı takma adlar -alır. Tarih benzeri kısım, modelin çıkış tarihi değil, sentetik bir rota -yuvasıdır. Gerçek Anthropic Claude rotaları kendi gerçek kimliklerini korur. +Anthropic harici rotalar, `claude-opus-4-8-YYYYMMDD` gibi kararlı takma adlar +alır; yıl 2026 ile 2035 arasındadır. Tarih benzeri kısım, modelin çıkış tarihi +değil, sentetik bir rota yuvasıdır. Önce 2026 yuvaları atanır, bu nedenle mevcut +takma adlar kimliklerini korur; sonraki yıllara ancak 2026 dolduktan sonra +geçilir. Gerçek Anthropic Claude rotaları kendi gerçek kimliklerini korur. Yeni rotalar varsayılan olarak Opus ailesine gider, ancak bir rotayı taşımak çağırdığı sağlayıcıyı veya modeli değiştirmez. Eski uygulama bayrakları `--static`, `--hybrid` ve `--discovery-only` mevcut betikler için kullanılabilir 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 436c995bdbd..17a8630c217 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -472,4 +472,6 @@ service stop` yerel Codex'i geri yükler. Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. Bu neden artık Codex yapılandırmasını, başvuru profilini veya model kataloğunu reddetmez. `ocx sync` ve `ocx start` bu dosyaları yazmaya ve `model_catalog_json` yolunu ayarlamaya devam eder; böylece Codex model seçicisi OpenCodex üzerinden yönlendirilen her modeli göstermeyi sürdürür. Konuşma geçmişinin yeniden etiketlenmesini durduran yalnızca bu nedendir, çünkü sayfalanmış geçmiş sıra numaralarını Codex’in kendi yerel yazıcısı atar ve yeniden denemek bunu değiştirmez. Okunamayan bir durum veritabanı, kimliği değişmiş bir geçmiş veya çalıştırılamayan bir ön kontrol gibi diğer geçmiş ön kontrol nedenleri, daha sonra başarılı olabilecekleri için hâlâ tüm değişimi reddeder ve geri alır. Bu durumda OpenCodex sayfalanmış geçmiş dosyalarını veya iş parçacığı satırlarını değiştirmez. Mevcut konuşmalar zaten etiketlendikleri sağlayıcıda kalır ve taşınmaz; yeni konuşmalar proxy üzerinden normal şekilde yönlendirilir. Yeniden etiketleme durduğunda, ev dizininde zaten bulunan bir `[model_providers.opencodex]` tablosu kaldırılmaz, kök-override (loopback) biçimde bile tutulur; böylece satırları `opencodex` olarak etiketlenmiş konuşmalar hâlâ var olan bir sağlayıcı kimliğini korur. CLI şunu yazdırır: `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` ve Codex yapılandırmasının kaldırılması `history_paginated_requires_native_writer` nedeniyle hâlâ reddedilir. İş parçacığı satırları hâlâ ona başvuruyken `[model_providers.opencodex]` tanımını kaldırmak o konuşmaları çözülemez yapar ve geri yükleme yolu uyumluluk sağlayıcı tablosunu tutamaz. Zaten sayfalanmış bir ev dizini şu anda ürün üzerinden kaldırılamaz; bu amaçlanan davranış değil, bilinen açık iştir. +Kök URL geçersiz kılma biçimine dönülürken OpenCodex, geçmiş ön kontrolü başarılı olsa bile yapılandırmayı kaydetmeden önce mevcut `[model_providers.opencodex]` tanımını korur. Böylece Codex, kayıttan sonra veya arka plan geçmiş işlemi başlarken geçmiş biçimini değiştirirse eski `opencodex` konuşmaları sağlayıcılarını bulmaya devam eder. Yeni konuşmalar seçili kök sağlayıcıyı kullanır; açıkça istenen geri yükleme, mevcut ayrı kaldırma kontrollerini korur. + Konuşmaları kendiniz taşımak için etkin sayfalanmış geçmişi veya iş parçacığı satırını yeniden yazmayın. Kurtarmadan önce konuşmayı kapatın ve özel geçmişi yayımlamadan tam hatayı ve sürümleri bildirin. Yedek veya başarılı betik görüntünün düzeldiğini kanıtlamaz; Codex’i yeniden açıp konuşmayı kontrol edin. diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index 6167bfe6df2..090be990616 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -268,15 +268,12 @@ tam olarak tekrarlasa bile, çünkü aşama doğruluğu kozmetik tekilleştirmed ### Akıl yürütme çabası -`gpt-5.6-sol` ve `claude-opus-5` doğrulanmış yerel çaba desteğine sahiptir ve -her model ailesi istek alanını farklı şekilde adlandırır. Seçilen `low`, -`medium`, `high`, `xhigh` veya `max` değeri `gpt-5.6-sol` için -`additionalModelRequestFields.reasoning.effort` olarak ve `claude-opus-5` için -`additionalModelRequestFields.output_config.effort` olarak gönderilir. Diğer -Kiro modelleri şu anda öykünülmüş akıl yürütme kullanır: opencodex yerel çaba -alanları doğrulanmadığı için seçilen seviyeyi kullanıcı içeriğinde sınırlı -düşünme talimatlarına dönüştürür. Bu modellerde bildirilen bir çaba denetimini -yukarı akış yerel akıl yürütme desteğinin kanıtı olarak yorumlamayın. +GPT-5.6 ailesi `additionalModelRequestFields.reasoning.effort`, `claude-opus-5` ise +`additionalModelRequestFields.output_config.effort` alanını kullanır. `gpt-5.6-luna` ve +`gpt-5.6-terra` için yalnızca doğrulanmış `low`, `medium`, `high` ve `max` seviyeleri yerel alandan +gönderilir. Bu iki modelin yerel `xhigh` seviyesi doğrulanmadığı için mevcut sınırlı düşünme +talimatlarıyla öykünme korunur. `gpt-5.6-sol` ve `claude-opus-5` için mevcut yerel `low`, `medium`, +`high`, `xhigh` ve `max` davranışı değişmez. Diğer Kiro modelleri öykünme kullanır; çaba seçeneği yerel desteğin kanıtı değildir. ## `cursor` diff --git a/docs-site/src/content/docs/tr/reference/architecture.md b/docs-site/src/content/docs/tr/reference/architecture.md index 5dd9b999574..f3350af415d 100644 --- a/docs-site/src/content/docs/tr/reference/architecture.md +++ b/docs-site/src/content/docs/tr/reference/architecture.md @@ -24,7 +24,8 @@ src/ ├── vision/ # vizyon sidecar'ı (açıklama + plan) ├── config.ts # ~/.opencodex/config.json, varsayılanlar, PID, ortam çözümleme ├── router.ts # model kimliği → sağlayıcı + adaptör -├── bridge.ts # AdapterEvent akışı → Responses SSE / JSON +├── bridge.ts # bridge/ üzerinde cephe +├── bridge/ # AdapterEvent akışı → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # akıl yürütme çabası çevirisi, sabitleme ve katalog seviyeleri ├── responses/ │ ├── parser.ts # Responses isteği → OcxParsedRequest @@ -35,19 +36,22 @@ src/ └── index.ts # genel giriş noktası ``` -Eskiden büyük olan üç giriş dosyası artık cepheler (facades) olarak uyumluluğu -korur: `codex/catalog.ts` odaklanmış yedi `codex/catalog/*.ts` modülünü dışa -aktarır, `server/management-api.ts` dokuz `server/management/*.ts` modülüne -dağıtır ve `server/responses.ts` beş `server/responses/*.ts` modülünü dışa -aktarır. +Eskiden büyük olan giriş dosyaları artık cepheler (facades) olarak uyumluluğu +korur: `codex/catalog.ts` `codex/catalog/*.ts` modüllerini dışa aktarır, +`server/management-api.ts` `server/management/*.ts` modüllerine dağıtır, +`server/responses.ts` `server/responses/*.ts` modüllerini dışa aktarır ve `bridge.ts` +`bridge/*.ts` modüllerini yeniden dışa aktarır. Cephe, uygulamanın kendisi değil +kararlı içe aktarma yoludur: aşağıdaki her adım kodun sahibi olan modülü +adlandırır ve Responses yüzeyinin tam sahiplik envanteri +`structure/transports/responses.md` dosyasındadır. ## İstek akışı -`server/index.ts` HTTP sınırına sahiptir ve Responses veri düzlemini +`server/index/serve-options.ts` HTTP sınırına sahiptir ve Responses veri düzlemini `server/responses.ts` cephesine ve onun `server/responses/*.ts` modüllerine devreder: -1. `server/index.ts` CORS ve API kimlik doğrulamasını uygular, boşaltma +1. `server/index/serve-options.ts` CORS ve API kimlik doğrulamasını uygular, boşaltma sırasında yeni işleri reddeder ve istek yaşam döngüsü meta verilerini kaydeder. `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST @@ -58,7 +62,7 @@ devreder: `/v1/live/{callId}` (ve `/v1/realtime?call_id=`) üzerindeki yan bant WebSocket katılımlarını ve `/v1/responses` üzerindeki isteğe bağlı WebSocket yükseltmesini sunar. -2. `server/responses/core.ts` JSON'ı açar ve ayrıştırır, kullanılabilir +2. `server/responses/request-prepare.ts` JSON'ı açar ve ayrıştırır, kullanılabilir olduğunda yerel olarak hatırlanan `previous_response_id` girdisini genişletir, ardından `responses/parser.ts`'yi çağırır. 3. `router.ts` yalın veya `sağlayıcı/model` kimliğini çözer. Sunucu daha sonra @@ -75,7 +79,7 @@ devreder: `web-search/` sentetik bir fonksiyon sunar, gerçek aramayı ChatGPT sidecar'ı aracılığıyla yürütür, sonuçları yönlendirilen modele geri besler ve yapılandırılmış döngü sınırı içinde tekrarlar. -7. `bridge.ts` Responses SSE veya JSON üretir. `server/request-log.ts` ve +7. `bridge/sse.ts` / `bridge/response-json.ts` Responses SSE veya JSON üretir. `server/request-log.ts` ve `usage/` yanıtı değiştirmeden uç durumu, gecikmeyi, sağlayıcı/model etiketlerini ve en iyi çaba belirteç kullanımını toplar. @@ -102,7 +106,7 @@ ardından bir `OcxParsedRequest` oluşturur: ## Köprü (Bridge) -`bridge.ts`, adaptörün dahili `AdapterEvent` akışını Codex'in anladığı Responses +`bridge/sse.ts`, adaptörün dahili `AdapterEvent` akışını Codex'in anladığı Responses SSE'ye dönüştürür: | AdapterEvent | Yayınlanan Responses SSE | @@ -164,7 +168,7 @@ tanılamaları için `usage/` tarafından toplanır. ## Aktarım ve sıkıştırma -`server/index.ts` varsayılan olarak `/v1/responses` üzerinde HTTP/SSE sunar. +`server/index/serve-options.ts` varsayılan olarak `/v1/responses` üzerinde HTTP/SSE sunar. Codex `websockets` `false` iken bir Responses WebSocket yükseltmesi denerse opencodex `426 upgrade_required` döndürür; Codex daha sonra bu oturum için HTTP'ye geri döner. `"websockets": true` ayarlandığında aynı uç nokta @@ -182,7 +186,7 @@ değiştirilmeden HTTP'ye geri dönülmeye devam edilir. Codex bağlam sıkıştırması yönlendirilen modeller için çalışır. `server/responses/compact.ts`, dahili bir yönlendirilen özetleme turu çalıştırarak ve sıkıştırılmış geçmişi döndürerek `POST /v1/responses/compact`'ı -işlerken, `responses/parser.ts` ve `bridge.ts` tam olarak bir sentetik +işlerken, `responses/parser.ts` ve `bridge/sse.ts` tam olarak bir sentetik `compaction` çıktı öğesi yayarak uzak sıkıştırma v2 `compaction_trigger` turlarını işler. diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index a36e60fed77..b3b79d854cd 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -36,7 +36,7 @@ yönetim API'sine gidiş-dönüş yapar. Durdurulmuş veya erişilemeyen bir pro yapılandırma işlemleri olarak açıkça belgelenen komutlar, bunun yerine canlı bir proxy olmadan yapılandırma dosyasını doğrulayabilir ve düzenleyebilir. -`ocx system codex-cli-update check` canlı proxy gerektirmez ve paket kayıt defterine istek göndermez. Yapılandırmada belirtilen kurulum adayına ilişkin provenance meta verilerini, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik denetim Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut yazılım kurmaz veya onarmaz, Codex ya da npm çalıştırmaz, çalışan bir sürece müdahale etmez ve yapılandırmaya ya da önbellek durumuna yazmaz. +`ocx system codex-cli-update check` canlı proxy gerektirmez ve paket kayıt defterine istek göndermez. Yapılandırmada belirtilen kurulum adayına ilişkin provenance meta verilerini, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik denetim Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve POSIX'te `candidate_unavailable`, Windows'ta ise `windows_inspection_deferred` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut yazılım kurmaz veya onarmaz, Codex ya da npm çalıştırmaz, çalışan bir sürece müdahale etmez ve yapılandırmaya ya da önbellek durumuna yazmaz. Belirsiz olmayan yerlerde liste veya durum varsayılandır. Yapılandırılmış anlık görüntüler için `--json` ve akışlı bir istek günlüğü akışı için `ocx observe diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index f533038cf5b..75a6f63bd16 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -304,7 +304,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` paket kayıt defterine istek göndermez ve yapılandırmada belirtilen kurulum adayına ilişkin provenance kanıtını, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik komut Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut Codex veya bir paket yöneticisi çalıştırmaz, shim'i onarmaz, yapılandırmaya ya da önbellek durumuna yazmaz, hiçbir süreci durdurmaz ve hiçbir şey kurmaz. Uygulamayla birlikte paketlenmiş adaylar, tanınan sürüm yöneticisi yollarında bulunan adaylar, doğrulanmamış bağımsız adaylar ve belirsiz shim durumları `unmanaged` veya `unknown` olarak raporlanır; hiçbir zaman `managed` olarak sınıflandırılmaz. +`check` paket kayıt defterine istek göndermez ve yapılandırmada belirtilen kurulum adayına ilişkin provenance kanıtını, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik komut Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve POSIX sistemlerinde `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Bu parça kalıcı seçim durumunu hiç okumadığından, ortam adayı yakalanmamış olan Windows çalıştırmaları `candidate_unavailable` yerine `windows_inspection_deferred` bildirir: komut bir Codex CLI'nin kurulu olup olmadığını gözlemleyemez, bu yüzden aday bulunmadığını iddia etmek yerine incelemenin ertelendiğini bildirir. Komut Codex veya bir paket yöneticisi çalıştırmaz, shim'i onarmaz, yapılandırmaya ya da önbellek durumuna yazmaz, hiçbir süreci durdurmaz ve hiçbir şey kurmaz. Uygulamayla birlikte paketlenmiş adaylar, tanınan sürüm yöneticisi yollarında bulunan adaylar, doğrulanmamış bağımsız adaylar ve belirsiz shim durumları `unmanaged` veya `unknown` olarak raporlanır; hiçbir zaman `managed` olarak sınıflandırılmaz. + +Windows üzerinde `CODEX_CLI_PATH=codex` gibi yalın bir komut, uzak yol veya aygıt yolu aday olarak yakalanırsa `candidate_path_unavailable` bildirilir. Aday yakalanmıştır; ancak yolu bu inceleme için uygun değildir. ### `ocx config ...` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 4e2175bb387..ca59f39db9d 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -266,6 +266,8 @@ yanıtlar ve geçersiz kataloglar, herhangi bir yerel yazma işleminden önce re doğrulama isteğe bağlıdır ve yalnızca ortam değişkeni adıyla (`--auth-env`) okunur, argv'den alınmaz. +`HTTP_PROXY` veya `http_proxy` geçerliyken `NO_PROXY` ya da `no_proxy` içinde eşleşen bir istisna yoksa loopback HTTP istekleri, kimlik doğrulama başlıkları eklenmeden ve herhangi bir istek gönderilmeden reddedilir. `ALL_PROXY`/`all_proxy` ve yalnızca `HTTPS_PROXY`/`https_proxy` ayarları bu HTTP kısıtlamasını tetiklemez; HTTPS üzerinden katalog alımına izin verilmeye devam edilir. Ret mesajı proxy adresini veya kimlik doğrulama belirtecini içermez. Boş olmayan `http_proxy` ve `no_proxy` değerleri sırasıyla `HTTP_PROXY` ve `NO_PROXY` değerlerinden önce gelir. Bun ile uyumlu proxy atlama kuralları için ana makine adları, eşleşen `host:port` girdileri, `[::1]` gibi köşeli parantez içindeki IPv6 adresleri veya `*` kullanın; URL, yol veya `*.` öneki kullanmayın. + Katalog ve önbellek, paylaşılan Codex katalog kilidi altında yazılır; bir hata durumunda last-known-good dosyalar korunur. Aynı baytlar, mtime değerlerini koruyan bir no-op'tur. `--restart-codex`, `--restart-app-server-only` ve kullanımdan kaldırılmış takma ad diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index bee889f6d86..11af8bb466c 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -370,6 +370,8 @@ ocx account main switch --yes [--json] ocx account main recover [--rollback --yes] [--json] ``` +`ocx account main reauth --device --no-wait --json` başarılı olduğunda stdout'a tek bir JSON nesnesi yazar; insan tarafından okunabilir `follow up:` satırını yazmaz. İlerlemeyi kontrol etmek için döndürülen `flowId` değerini `ocx account main reauth status --flow --json` komutuna iletin. + Değiştiren her komut çalışan proxy tarafından döndürülen kurallı etkin `CODEX_HOME`'u bildirir. Bu yol arayanın `CODEX_HOME`'undan farklı olabilir; JSON'ı destekleyen komutlar aynı değeri `effectiveCodexHome` olarak açığa 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 dfdac6d98ac..13612608e93 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 @@ -358,4 +358,6 @@ ocx restore back # point plain Codex at the running proxy again 如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。该原因不再拒绝写入 Codex 配置、参考配置档和模型目录。`ocx sync` 与 `ocx start` 仍会写入这些文件并设置 `model_catalog_json`,因此 Codex 模型选择器会继续显示所有经 OpenCodex 路由的模型。只有这一条原因会让会话历史的重新标记停手,因为分页历史序号由 Codex 自己的写入器分配,重试也不会改变。无法读取的状态数据库、身份已变的历史文件、未能运行的预检等其他历史预检原因仍会拒绝整个切换并回滚,因为那些情况以后可能成功。在此状态下,OpenCodex 不会修改分页历史文件或线程行。现有会话保留已标记的提供商,不会被迁移;新会话仍正常经代理路由。重新标记停手时,主目录里已有的 `[model_providers.opencodex]` 表会保留而不是撤下,即便是 root-override(loopback)形式也一样,这样行上标记为 `opencodex` 的会话仍能对应到还存在的提供商 id。可迁移存储中的 legacy 记录也适用。CLI 会打印 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 和移除 Codex 配置仍会因 `history_paginated_requires_native_writer` 被拒绝。线程行仍在引用时撤掉 `[model_providers.opencodex]` 定义会使这些会话无法解析,而恢复路径没有办法留下兼容提供商表。已经分页的主目录目前无法通过产品卸载;这是已知的未完成工作,而非预期行为。 +返回根 URL 覆盖模式时,即使历史预检通过,OpenCodex 也会在提交配置前保留已有的 `[model_providers.opencodex]` 定义。这样,即使 Codex 在提交后或后台历史任务启动时迁移历史格式,旧的 `opencodex` 对话仍能找到其提供商。新对话继续使用所选的根提供商;显式恢复仍执行原有的独立删除检查。 + 不要改写正在使用的分页历史文件或线程行来自行迁移这些会话。恢复前关闭相关会话,并只报告准确的错误和版本,不要公开私人历史。备份或脚本成功并不能证明显示已恢复;重新打开 Codex 后检查会话。 diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index e111743887b..a970fd1d6c9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -154,10 +154,12 @@ Kiro 的 assistant 文本本身没有可靠的回合结束标记,但终止的 ### Reasoning effort -`gpt-5.6-sol` 和 `claude-opus-5` 支持原生 effort,且请求字段名不同。`low` / `medium` / `high` / -`xhigh` / `max` 分别通过 `additionalModelRequestFields.reasoning.effort` 和 -`output_config.effort` 发送。 - +GPT-5.6 系列使用 `additionalModelRequestFields.reasoning.effort`,`claude-opus-5` 使用 +`additionalModelRequestFields.output_config.effort`。`gpt-5.6-luna` 和 `gpt-5.6-terra` +仅通过原生字段发送已验证的 `low`、`medium`、`high` 和 `max`。 +这两个模型的原生 `xhigh` 尚未验证,因此仍使用原有的有界 thinking 指令模拟。 +`gpt-5.6-sol` 和 `claude-opus-5` 保留现有原生档位(`low`、`medium`、`high`、`xhigh`、`max`)。 +其他 Kiro 模型使用模拟推理;提供 effort 选项并不代表原生支持。 ## `cursor` diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index b1edbd1e8bb..8bf59e184bb 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -23,7 +23,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -34,24 +35,26 @@ src/ └── index.ts # public entry ``` -原先的三个大型入口文件现在是兼容性 facade:`codex/catalog.ts` 导出 7 个 -`codex/catalog/*.ts` 模块,`server/management-api.ts` 分派到 9 个 -`server/management/*.ts` 模块,而 `server/responses.ts` 导出 5 个 -`server/responses/*.ts` 模块。 +原先的大型入口文件现在是兼容性 facade:`codex/catalog.ts` 导出 +`codex/catalog/*.ts` 模块,`server/management-api.ts` 分派到 +`server/management/*.ts` 模块,`server/responses.ts` 导出 `server/responses/*.ts` +模块,而 `bridge.ts` 重新导出 `bridge/*.ts` 模块。facade 只是稳定的导入路径,而不是实现: +下面每一步都指向真正拥有代码的模块,Responses 面的完整归属清单见 +`structure/transports/responses.md`。 ## 请求流程 -`server/index.ts` 负责 HTTP 边界,并把 Responses data plane 交给 `server/responses.ts` facade +`server/index/serve-options.ts` 负责 HTTP 边界,并把 Responses data plane 交给 `server/responses.ts` facade 及其 `server/responses/*.ts` 模块: -1. `server/index.ts` 应用 CORS 和 API 认证,在 drain 期间拒绝新请求,并记录请求生命周期 +1. `server/index/serve-options.ts` 应用 CORS 和 API 认证,在 drain 期间拒绝新请求,并记录请求生命周期 metadata。它提供 `GET /v1/models`、`POST /v1/responses`、 `POST /v1/responses/compact`、`POST /v1/images/generations` / `POST /v1/images/edits` (供 Codex 内置 `image_gen` 工具使用——由 `server/images.ts` 中继到 OpenAI 系上游)、 `POST /v1/live` / `POST /v1/realtime/calls`(ChatGPT / Codex App 语音与 OpenAI Realtime 建连,由 `server/live.ts` 中继)、`/v1/live/{callId}` 旁路 WebSocket, 以及 `/v1/responses` 上可选的 WebSocket upgrade。 -2. `server/responses/core.ts` 解压并解析 JSON;如果本地记住了对应输入,则展开 +2. `server/responses/request-prepare.ts` 解压并解析 JSON;如果本地记住了对应输入,则展开 `previous_response_id`,随后调用 `responses/parser.ts`。 3. `router.ts` 解析 bare id 或 `provider/model` id。server 随后确定 Codex account affinity, 必要时刷新 provider OAuth,并把选中的 credential 应用到 route。 @@ -62,7 +65,7 @@ src/ 则构建、获取并解析上游请求。 6. 路由模型请求托管的 `web_search` 工具时,`web-search/` 会暴露一个合成函数,经 ChatGPT sidecar 执行真实搜索,把结果送回路由模型,并在配置的循环上限内重复。 -7. `bridge.ts` 生成 Responses SSE 或 JSON。`server/request-log.ts` 与 `usage/` 在不改变响应的 +7. `bridge/sse.ts` / `bridge/response-json.ts` 生成 Responses SSE 或 JSON。`server/request-log.ts` 与 `usage/` 在不改变响应的 前提下收集终止状态、延迟、provider/model 标签和尽力估算的 token usage。 ## 解析器 @@ -85,7 +88,7 @@ src/ ## 桥接器 -`bridge.ts` 把 adapter 的内部 `AdapterEvent` 流转换回 Codex 能理解的 Responses SSE: +`bridge/sse.ts` 把 adapter 的内部 `AdapterEvent` 流转换回 Codex 能理解的 Responses SSE: | AdapterEvent | 发出的 Responses SSE | | --- | --- | @@ -129,7 +132,7 @@ thread affinity 位于 `codex/` 下,不会出现在管理 API 响应中。请 ## 传输与 compaction -`server/index.ts` 默认在 `/v1/responses` 上提供 HTTP/SSE。当 `websockets` 为 `false` 而 Codex +`server/index/serve-options.ts` 默认在 `/v1/responses` 上提供 HTTP/SSE。当 `websockets` 为 `false` 而 Codex 尝试 Responses WebSocket upgrade 时,opencodex 会返回 `426 upgrade_required`,Codex 随后在该 session 中回退到 HTTP。设置 `"websockets": true` 后,同一 endpoint 会接受 upgrade 并使用 WebSocket bridge。 @@ -143,7 +146,7 @@ WebSocket bridge。 Codex context compaction 同样适用于路由模型。`server/responses/compact.ts` 处理 `POST /v1/responses/compact`,运行一次内部路由 summarization turn 并返回压缩后的历史; -`responses/parser.ts` 与 `bridge.ts` 则处理 remote compaction v2 的 `compaction_trigger` turn, +`responses/parser.ts` 与 `bridge/sse.ts` 则处理 remote compaction v2 的 `compaction_trigger` turn, 准确发出一个合成的 `compaction` 输出 item。 ## 缓存与目录 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index e804df71af7..07ba562646b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -17,7 +17,7 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 管理命令会通过实时代理的管理 API 往返调用,使用记录下来的运行时端口和身份检查,而不是维护第二条配置路径。已停止或不可达的代理会被表示为 HTTP 503,并导致 CLI 以非零状态退出。明确标注为离线配置操作的命令,则可以在没有实时代理的情况下验证并编辑配置文件。 -`ocx system codex-cli-update check` 不需要实时代理,也不会向软件包注册表发起请求。它只会在限定范围内检查已配置候选项的来源元数据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性检查命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会安装或修复软件,不会运行 Codex 或 npm,不会控制正在运行的进程,也不会写入配置或缓存状态。 +`ocx system codex-cli-update check` 不需要实时代理,也不会向软件包注册表发起请求。它只会在限定范围内检查已配置候选项的来源元数据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性检查命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 POSIX 下的 `candidate_unavailable` 或 Windows 下的 `windows_inspection_deferred`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会安装或修复软件,不会运行 Codex 或 npm,不会控制正在运行的进程,也不会写入配置或缓存状态。 在语义明确时,默认操作是 `list` 或 `status`。使用 `--json` 获取结构化快照,使用 `ocx observe logs --follow --jsonl` 获取流式请求日志。主题、语言、导航以及其他纯视觉浏览器状态都没有 CLI 对应项;Cloudflare Tunnel 的设置不在这组命令之内。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index da81deadd6c..2dd2fc441b4 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -203,7 +203,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不会向软件包注册表发起请求,只会在限定范围内检查已配置候选项的来源证据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会运行 Codex 或软件包管理器,不会修复 shim,不会写入配置或缓存,不会停止进程,也不会安装任何内容。随应用捆绑的候选项、位于已识别版本管理器路径中的候选项、未经验证的独立候选项以及 shim 状态不明确的候选项,都会报告为 `unmanaged` 或 `unknown`,绝不会归类为 `managed`。 +`check` 不会向软件包注册表发起请求,只会在限定范围内检查已配置候选项的来源证据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能在 POSIX 系统上报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。由于这个切片完全不读取持久化的选择状态,在未捕获任何环境候选项的 Windows 运行中会报告 `windows_inspection_deferred` 而非 `candidate_unavailable`:该命令无法观测 Codex CLI 是否已安装,因此报告检查被推迟,而不是断言不存在候选项。该命令不会运行 Codex 或软件包管理器,不会修复 shim,不会写入配置或缓存,不会停止进程,也不会安装任何内容。随应用捆绑的候选项、位于已识别版本管理器路径中的候选项、未经验证的独立候选项以及 shim 状态不明确的候选项,都会报告为 `unmanaged` 或 `unknown`,绝不会归类为 `managed`。 + +在 Windows 上,如果捕获到 `CODEX_CLI_PATH=codex` 这样的裸命令、远程路径或设备路径,则报告 `candidate_path_unavailable`。这些情况下候选项已被捕获,但其路径不适用于此检查。 ### `ocx config ...` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 39787e18f03..2c5e3a49150 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -164,6 +164,8 @@ ocx status --json 安装由另一个 OpenCodex 实例的 `/v1/catalog` 端点提供的完整目录,然后同步 `models_cache.json`。URL 必须是 HTTPS;仅回环地址允许 HTTP。URL 内嵌凭据、查询、片段、重定向、超出大小的响应以及无效目录,都会在任何本地写入之前被拒绝。认证是可选的,并且只通过环境变量名(`--auth-env`)读取,不接受 argv 传入。 +如果 `HTTP_PROXY` 或 `http_proxy` 生效,且 `NO_PROXY` 或 `no_proxy` 中没有匹配的绕过规则,回环 HTTP 请求会在添加认证标头或发送请求之前被拒绝。`ALL_PROXY`/`all_proxy` 以及仅设置 `HTTPS_PROXY`/`https_proxy` 的情况不会触发此 HTTP 限制;仍允许通过 HTTPS 获取目录。拒绝消息不会包含代理地址或认证令牌。 非空的 `http_proxy` 和 `no_proxy` 分别优先于 `HTTP_PROXY` 和 `NO_PROXY`。要设置与 Bun 兼容的代理绕过规则,请使用主机名、匹配的 `host:port`、`[::1]` 等带方括号的 IPv6 地址或 `*`,不要使用 URL、路径或 `*.` 前缀。 + 目录和缓存在共享的 Codex 目录锁下写入;失败时保留 last-known-good 文件。字节完全相同时是保留 mtime 的空操作。`--restart-codex`、`--restart-app-server-only` 以及已弃用别名 `--restart-desktop-app` 仅在发生真实写入之后生效,含义与 `ocx sync` / `ocx sync-cache` 相同。`ETag` 条件请求不属于此命令。完整的 `--json` 信封与退出码请参见[英文参考](/reference/cli/lifecycle/)。 ## 后台服务 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 181b2aa4e95..3ad7fac9f8e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -280,6 +280,8 @@ ocx account main switch --yes [--json] ocx account main recover [--rollback --yes] [--json] ``` +`ocx account main reauth --device --no-wait --json` 成功时只向 stdout 输出一个 JSON 对象,不输出供人阅读的 `follow up:` 提示行。将返回的 `flowId` 传给 `ocx account main reauth status --flow --json` 即可查看进度。 + 每个变更命令都会显示运行中代理返回的规范化有效 `CODEX_HOME`。该路径可能与调用进程的 `CODEX_HOME` 不同;支持 JSON 的命令会在 `effectiveCodexHome` 中返回相同的值。 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 2c1a995cc3d..338b19c7ee6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -129,8 +129,9 @@ ocx claude desktop import [--apply] 檔案,因此無效檔案不會改動目前設定檔。加上 `--apply` 可在匯入有效設定檔後立即寫入 Desktop。 `none` 僅適用於空系列;每個非空系列都必須保留一個預設。 -非 Anthropic 路由會得到穩定別名,例如 `claude-opus-4-8-2026MMDD`。看起來像日期的部分是合成的 -路由槽位,不是模型釋出日期。真正的 Anthropic Claude 路由保留真實 id。新路由預設落在 Opus +非 Anthropic 路由會得到穩定別名,例如 `claude-opus-4-8-YYYYMMDD`,年份範圍為 2026 至 2035。看起來像日期的部分是合成的 +路由槽位,不是模型釋出日期。系統會先配置 2026 的槽位,因此既有別名的 id 不變;2026 用盡後才會用到後續年份。 +真正的 Anthropic Claude 路由保留真實 id。新路由預設落在 Opus 系列,但移動路由不會改變它所呼叫的供應商或模型。舊版 apply 旗標 `--static`、`--hybrid` 與 `--discovery-only` 仍可供既有腳本使用。 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 284034b54b0..f7bfefac138 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 @@ -365,4 +365,6 @@ ocx restore back # 讓普通 Codex 再次指向仍在執行的 proxy 如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。此原因不再拒絕寫入 Codex 設定、參考設定檔與模型目錄。`ocx sync` 與 `ocx start` 仍會寫入這些檔案並設定 `model_catalog_json`,因此 Codex 模型選擇器會繼續顯示所有經 OpenCodex 路由的模型。只有這一條原因會讓對話歷史的重新標記停手,因為分頁歷史序號由 Codex 自己的寫入器分配,重試也不會改變。無法讀取的狀態資料庫、身分已變的歷史檔案、未能執行的預檢等其他歷史預檢原因仍會拒絕整個切換並回復,因為那些情況以後可能成功。在此狀態下,OpenCodex 不會修改分頁歷史檔案或執行緒列。既有對話保留已標記的提供者,不會被遷移;新對話仍正常經代理路由。重新標記停手時,家目錄裡既有的 `[model_providers.opencodex]` 表會保留而不是撤下,即便是 root-override(loopback)形式也一樣,這樣列上標記為 `opencodex` 的對話仍能對應到還存在的提供者 id。可遷移儲存區中的 legacy 記錄也適用。CLI 會印出 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 與移除 Codex 設定仍會因 `history_paginated_requires_native_writer` 被拒絕。執行緒列仍在參照時撤掉 `[model_providers.opencodex]` 定義會使這些對話無法解析,而復原路徑沒有辦法留下相容提供者表。已經分頁的家目錄目前無法透過產品解除安裝;這是已知的未完成工作,而非預期行為。 +返回根 URL 覆寫模式時,即使歷史預檢通過,OpenCodex 也會在提交設定前保留既有的 `[model_providers.opencodex]` 定義。如此一來,即使 Codex 在提交後或背景歷史工作啟動時遷移歷史格式,舊的 `opencodex` 對話仍能找到其提供者。新對話繼續使用所選的根提供者;明確要求的還原仍執行原有的獨立刪除檢查。 + 請勿改寫使用中的分頁歷史檔案或執行緒列來自行遷移這些對話。復原前關閉相關對話,只回報確切錯誤與版本,不要公開私人歷史。備份或指令碼成功不能證明顯示已復原;重新開啟 Codex 後確認對話。 diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index c155c8ae517..2b314cd2992 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -145,10 +145,12 @@ Kiro 的 assistant 文字本身沒有可靠的回合結束標記,但終止的 ### Reasoning effort -`gpt-5.6-sol` 和 `claude-opus-5` 支援原生 effort,且請求欄位名不同。`low` / `medium` / `high` / -`xhigh` / `max` 分別透過 `additionalModelRequestFields.reasoning.effort` 和 -`output_config.effort` 傳送。 - +GPT-5.6 系列使用 `additionalModelRequestFields.reasoning.effort`,`claude-opus-5` 使用 +`additionalModelRequestFields.output_config.effort`。`gpt-5.6-luna` 和 `gpt-5.6-terra` +只透過原生欄位傳送已驗證的 `low`、`medium`、`high` 和 `max`。 +這兩個模型的原生 `xhigh` 尚未驗證,因此仍使用原有的有界 thinking 指令模擬。 +`gpt-5.6-sol` 和 `claude-opus-5` 保留現有原生檔位(`low`、`medium`、`high`、`xhigh`、`max`)。 +其他 Kiro 模型使用模擬推理;提供 effort 選項不代表原生支援。 ## `cursor` diff --git a/docs-site/src/content/docs/zh-tw/reference/architecture.md b/docs-site/src/content/docs/zh-tw/reference/architecture.md index be0b2c50036..cb246c8bd05 100644 --- a/docs-site/src/content/docs/zh-tw/reference/architecture.md +++ b/docs-site/src/content/docs/zh-tw/reference/architecture.md @@ -23,7 +23,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -34,24 +35,26 @@ src/ └── index.ts # public entry ``` -原先的三個大型入口檔案現在是相容性 facade:`codex/catalog.ts` 匯出 7 個 -`codex/catalog/*.ts` 模組,`server/management-api.ts` 分派到 9 個 -`server/management/*.ts` 模組,而 `server/responses.ts` 匯出 5 個 -`server/responses/*.ts` 模組。 +原先的大型入口檔案現在是相容性 facade:`codex/catalog.ts` 匯出 +`codex/catalog/*.ts` 模組,`server/management-api.ts` 分派到 +`server/management/*.ts` 模組,`server/responses.ts` 匯出 `server/responses/*.ts` +模組,而 `bridge.ts` 重新匯出 `bridge/*.ts` 模組。facade 只是穩定的匯入路徑,而不是實作: +下面每一步都指向真正擁有程式碼的模組,Responses 面的完整歸屬清單見 +`structure/transports/responses.md`。 ## 請求流程 -`server/index.ts` 負責 HTTP 邊界,並把 Responses data plane 交給 `server/responses.ts` facade +`server/index/serve-options.ts` 負責 HTTP 邊界,並把 Responses data plane 交給 `server/responses.ts` facade 及其 `server/responses/*.ts` 模組: -1. `server/index.ts` 應用 CORS 和 API 認證,在 drain 期間拒絕新請求,並記錄請求生命週期 +1. `server/index/serve-options.ts` 應用 CORS 和 API 認證,在 drain 期間拒絕新請求,並記錄請求生命週期 metadata。它提供 `GET /v1/models`、`POST /v1/responses`、 `POST /v1/responses/compact`、`POST /v1/images/generations` / `POST /v1/images/edits` (供 Codex 內建 `image_gen` 工具使用——由 `server/images.ts` 中繼到 OpenAI 繫上遊)、 `POST /v1/live` / `POST /v1/realtime/calls`(ChatGPT / Codex App 語音與 OpenAI Realtime 建連,由 `server/live.ts` 中繼)、`/v1/live/{callId}` 旁路 WebSocket, 以及 `/v1/responses` 上可選的 WebSocket upgrade。 -2. `server/responses/core.ts` 解壓並解析 JSON;如果本機記住了對應輸入,則展開 +2. `server/responses/request-prepare.ts` 解壓並解析 JSON;如果本機記住了對應輸入,則展開 `previous_response_id`,隨後呼叫 `responses/parser.ts`。 3. `router.ts` 解析 bare id 或 `provider/model` id。server 隨後確定 Codex account affinity, 必要時重新整理 provider OAuth,並把選中的 credential 應用到 route。 @@ -62,7 +65,7 @@ src/ 則建置、取得並解析上游請求。 6. 路由模型請求託管的 `web_search` 工具時,`web-search/` 會暴露一個合成函式,經 ChatGPT sidecar 執行真實搜尋,把結果送回路由模型,並在設定的迴圈上限內重複。 -7. `bridge.ts` 生成 Responses SSE 或 JSON。`server/request-log.ts` 與 `usage/` 在不改變回應的 +7. `bridge/sse.ts` / `bridge/response-json.ts` 生成 Responses SSE 或 JSON。`server/request-log.ts` 與 `usage/` 在不改變回應的 前提下收集終止狀態、延遲、provider/model 標籤和盡力估算的 token usage。 ## 解析器 @@ -85,7 +88,7 @@ src/ ## 橋接器 -`bridge.ts` 把 adapter 的內部 `AdapterEvent` 流轉換回 Codex 能理解的 Responses SSE: +`bridge/sse.ts` 把 adapter 的內部 `AdapterEvent` 流轉換回 Codex 能理解的 Responses SSE: | AdapterEvent | 發出的 Responses SSE | | --- | --- | @@ -129,7 +132,7 @@ thread affinity 位於 `codex/` 下,不會出現在管理 API 回應中。請 ## 傳輸與 compaction -`server/index.ts` 預設在 `/v1/responses` 上提供 HTTP/SSE。當 `websockets` 為 `false` 而 Codex +`server/index/serve-options.ts` 預設在 `/v1/responses` 上提供 HTTP/SSE。當 `websockets` 為 `false` 而 Codex 嘗試 Responses WebSocket upgrade 時,opencodex 會回傳 `426 upgrade_required`,Codex 隨後在該 session 中回退到 HTTP。設定 `"websockets": true` 後,同一 endpoint 會接受 upgrade 並使用 WebSocket bridge。 @@ -144,7 +147,7 @@ WebSocket bridge。 Codex context compaction 同樣適用於路由模型。`server/responses/compact.ts` 處理 `POST /v1/responses/compact`,執行一次內部路由 summarization turn 並回傳壓縮後的歷史; -`responses/parser.ts` 與 `bridge.ts` 則處理 remote compaction v2 的 `compaction_trigger` turn, +`responses/parser.ts` 與 `bridge/sse.ts` 則處理 remote compaction v2 的 `compaction_trigger` turn, 準確發出一個合成的 `compaction` 輸出 item。 ## 快取與目錄 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index 81121d4f51a..eef78a98c9a 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -28,7 +28,7 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 設定路徑。停止或無法連線的代理以 HTTP 503 呈現,並產生非零的 CLI 離開碼。明確記載為 離線設定操作的命令,可以在沒有執行中代理的情況下驗證與編輯設定檔。 -`ocx system codex-cli-update check` 不需要執行中的代理,也不會向套件 registry 發出請求。它只會在限定範圍內檢查設定中的安裝候選項來源中繼資料,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次檢查命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會安裝或修復軟體、不會執行 Codex 或 npm、不會控制執行中的程序,也不會寫入設定或快取狀態。 +`ocx system codex-cli-update check` 不需要執行中的代理,也不會向套件 registry 發出請求。它只會在限定範圍內檢查設定中的安裝候選項來源中繼資料,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次檢查命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 POSIX 下的 `candidate_unavailable` 或 Windows 下的 `windows_inspection_deferred`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會安裝或修復軟體、不會執行 Codex 或 npm、不會控制執行中的程序,也不會寫入設定或快取狀態。 沒有歧義時,list 或 status 是預設。使用 `--json` 取得結構化快照,並以 `ocx observe logs --follow --jsonl` 取得串流的請求 log feed。佈景主題、語言、導覽與 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 7da97b61dca..eea538f4837 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -206,7 +206,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不會向套件 registry 發出請求,只會在限定範圍內檢查設定中的安裝候選項來源證據,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會執行 Codex 或套件管理工具、不會修復 shim、不會寫入設定或快取、不會停止程序,也不會安裝任何內容。隨應用程式封裝的候選項、位於已識別版本管理工具路徑中的候選項、未經驗證的獨立候選項,以及 shim 狀態不明確的候選項,都會報告為 `unmanaged` 或 `unknown`,絕不會歸類為 `managed`。 +`check` 不會向套件 registry 發出請求,只會在限定範圍內檢查設定中的安裝候選項來源證據,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能在 POSIX 系統上報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。由於這個切片完全不會讀取持久化的選擇狀態,在未擷取任何環境候選項的 Windows 執行中會報告 `windows_inspection_deferred` 而非 `candidate_unavailable`:該命令無法觀測 Codex CLI 是否已安裝,因此會報告檢查被延後,而不是斷言候選項不存在。此命令不會執行 Codex 或套件管理工具、不會修復 shim、不會寫入設定或快取、不會停止程序,也不會安裝任何內容。隨應用程式封裝的候選項、位於已識別版本管理工具路徑中的候選項、未經驗證的獨立候選項,以及 shim 狀態不明確的候選項,都會報告為 `unmanaged` 或 `unknown`,絕不會歸類為 `managed`。 + +在 Windows 上,如果擷取到 `CODEX_CLI_PATH=codex` 這類單純命令名稱、遠端路徑或裝置路徑,則回報 `candidate_path_unavailable`。這些情況已有擷取的候選項,但其路徑不適用於此檢查。 ### `ocx config ...` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 02421b11bd9..117133bc187 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -158,6 +158,8 @@ ocx status --json 安裝由另一個 OpenCodex 執行個體的 `/v1/catalog` 端點提供的完整目錄,接著同步 `models_cache.json`。URL 必須是 HTTPS;僅回送位址允許 HTTP。URL 內嵌憑證、查詢、片段、重新導向、超出大小的回應以及無效目錄,都會在任何本機寫入之前遭拒。驗證為選用,且只透過環境變數名稱(`--auth-env`)讀取,不接受 argv 傳入。 +如果 `HTTP_PROXY` 或 `http_proxy` 生效,且 `NO_PROXY` 或 `no_proxy` 中沒有相符的略過規則,回送 HTTP 要求會在加入驗證標頭或送出要求之前遭拒。`ALL_PROXY`/`all_proxy` 以及僅設定 `HTTPS_PROXY`/`https_proxy` 的情況不會觸發此 HTTP 限制;仍允許透過 HTTPS 取得目錄。拒絕訊息不會包含代理位址或驗證權杖。 非空的 `http_proxy` 和 `no_proxy` 分別優先於 `HTTP_PROXY` 和 `NO_PROXY`。若要設定與 Bun 相容的代理略過規則,請使用主機名稱、相符的 `host:port`、`[::1]` 等含方括號的 IPv6 位址或 `*`,不要使用 URL、路徑或 `*.` 前綴。 + 目錄與快取在共用的 Codex 目錄鎖之下寫入;失敗時保留 last-known-good 檔案。位元組完全相同時是保留 mtime 的無操作。`--restart-codex`、`--restart-app-server-only` 以及已棄用別名 `--restart-desktop-app` 僅在實際寫入之後生效,含義與 `ocx sync` / `ocx sync-cache` 相同。`ETag` 條件式請求不屬於此命令。完整的 `--json` 信封與結束碼請參見[英文參考](/reference/cli/lifecycle/)。 ## 背景服務 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 0dbe9f071d5..f0cdd97dddf 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -228,6 +228,8 @@ ocx account main switch --yes [--json] ocx account main recover [--rollback --yes] [--json] ``` +`ocx account main reauth --device --no-wait --json` 成功時只會向 stdout 輸出一個 JSON 物件,不會輸出供人閱讀的 `follow up:` 提示行。將回傳的 `flowId` 傳給 `ocx account main reauth status --flow --json` 即可查看進度。 + 每個會變更狀態的命令都會回報執行中代理回傳的 canonical 有效 `CODEX_HOME`。這個路徑可能與 呼叫端的 `CODEX_HOME` 不同;支援 JSON 的命令以 `effectiveCodexHome` 暴露同一個值。 diff --git a/gui/README.md b/gui/README.md index 10b22091689..e55b038fb64 100644 --- a/gui/README.md +++ b/gui/README.md @@ -52,3 +52,32 @@ bun run setup:hooks # pre-push runs doctor when gui/ changed | **React Doctor** (`bun run doctor`) | Gating React health check pinned to react-doctor 0.9.11 (`blocking: warning`). Pre-push runs it only if `gui/` changed and fails the push on findings. The CI workflow fails the job on any finding | Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage. + +## Sidebar version browser regression + +```bash +cd gui +bun run build +bun run test:sidebar-version +``` + +This opt-in check uses an installed Chrome/Chromium through its local DevTools +protocol, with no Playwright dependency or automatic browser download. Set +`CHROME_BIN` to the executable when it is not on PATH (including macOS/Windows). +It fails with an actionable error when the browser or production build is missing; +it does not silently skip assertions. + +The offline fixture uses the production CSS bundle and App header markup, not +copied CSS rules. Across 128 combinations of light/dark theme, eight viewport +widths, four release/prerelease/build strings and two font sizes, it verifies full +text visibility, containment, short release text staying on one line, and +no intersection with the mobile drawer close button. A short badge stays beside +the product name whenever the measured row budget allows it; larger OS font +fallbacks may move the complete badge below the name rather than clip it. Results and a screenshot are +written to `.tmp/sidebar-version-browser/`; pass an output directory after the +command to change it. No management API, proxy credentials, or live providers are +used. + +Chrome's sandbox stays enabled by default. `CHROME_NO_SANDBOX=1` is an explicit +opt-in only for an already-isolated root test container that cannot run Chrome's +sandbox; it is not needed or recommended on a normal workstation. diff --git a/gui/package.json b/gui/package.json index cd3d1b35d9b..cd9d7fcf917 100644 --- a/gui/package.json +++ b/gui/package.json @@ -11,7 +11,8 @@ "lint:i18n": "oxlint src/pages src/components src/App.tsx src/main.tsx src/ui.tsx src/provider-workspace-data.ts", "doctor": "npx --yes react-doctor@0.9.11 --verbose --scope changed --base origin/main --no-telemetry", "doctor:full": "npx --yes react-doctor@0.9.11 --verbose --scope full --no-telemetry", - "preview": "vite preview" + "preview": "vite preview", + "test:sidebar-version": "bun tests/sidebar-version-browser.ts" }, "dependencies": { "@tanstack/react-virtual": "^3.14.9", diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 70b62515d9b..dd5ca2633c1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1822,6 +1822,8 @@ export const de: Record = { "usage.dayWed": "Mi", "usage.dayFri": "Fr", "usage.heatmap.tooltipTokens": "{tokens} Tokens", + "usage.chart.dayDetail": "{date}: {requests} Anfragen, {tokens} Token", + "usage.heatmap.keyboardLabel": "Mit Hoch und Runter tageweise, mit Links und Rechts wochenweise navigieren.", "usage.heatmap.tooltipRequests": "{requests} Anfragen", "nav.storage": "Speicher", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index de2b8906f8d..6723d74b815 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1004,6 +1004,8 @@ export const en = { "usage.dayWed": "Wed", "usage.dayFri": "Fri", "usage.heatmap.tooltipTokens": "{tokens} tokens", + "usage.chart.dayDetail": "{date}: {requests} requests, {tokens} tokens", + "usage.heatmap.keyboardLabel": "Use Up and Down to move by day; Left and Right to move by week.", "usage.heatmap.tooltipRequests": "{requests} requests", "nav.storage": "Storage", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5b9a38a5883..43d956906fd 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -981,6 +981,8 @@ export const fr: Record = { "usage.dayWed": "Mer", "usage.dayFri": "Ven", "usage.heatmap.tooltipTokens": "{tokens} jetons", + "usage.chart.dayDetail": "{date} : {requests} requêtes, {tokens} jetons", + "usage.heatmap.keyboardLabel": "Utilisez Haut et Bas pour changer de jour ; Gauche et Droite pour changer de semaine.", "usage.heatmap.tooltipRequests": "{requests} requêtes", "nav.storage": "Stockage", "storage.title": "Stockage", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index b21d4d59b7b..d60148bb603 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -917,6 +917,8 @@ export const ja: Record = { "usage.dayWed": "水", "usage.dayFri": "金", "usage.heatmap.tooltipTokens": "{tokens} トークン", + "usage.chart.dayDetail": "{date}: {requests} リクエスト、{tokens} トークン", + "usage.heatmap.keyboardLabel": "上下キーで日単位、左右キーで週単位に移動します。", "usage.heatmap.tooltipRequests": "{requests} リクエスト", "nav.storage": "ストレージ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a6e76eb7b1f..be26daf40ed 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1861,6 +1861,8 @@ export const ko: Record = { "usage.dayWed": "수", "usage.dayFri": "금", "usage.heatmap.tooltipTokens": "{tokens} 토큰", + "usage.chart.dayDetail": "{date}: 요청 {requests}개, 토큰 {tokens}개", + "usage.heatmap.keyboardLabel": "위아래 화살표로 하루씩, 좌우 화살표로 일주일씩 이동합니다.", "usage.heatmap.tooltipRequests": "{requests} 요청", "nav.storage": "저장소", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c28f4464c3b..9691f8a49f4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -972,6 +972,8 @@ export const ru: Record = { "usage.dayWed": "Ср", "usage.dayFri": "Пт", "usage.heatmap.tooltipTokens": "{tokens} токенов", + "usage.chart.dayDetail": "{date}: {requests} запросов, {tokens} токенов", + "usage.heatmap.keyboardLabel": "Стрелки вверх и вниз перемещают по дням, влево и вправо — по неделям.", "usage.heatmap.tooltipRequests": "{requests} запросов", "nav.storage": "Хранилище", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c6671c3e288..95336fa32fd 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -991,6 +991,8 @@ export const tr: Record = { "usage.dayWed": "Çar", "usage.dayFri": "Cum", "usage.heatmap.tooltipTokens": "{tokens} jeton", + "usage.chart.dayDetail": "{date}: {requests} istek, {tokens} jeton", + "usage.heatmap.keyboardLabel": "Gün gün ilerlemek için Yukarı ve Aşağı, hafta hafta ilerlemek için Sol ve Sağ tuşlarını kullanın.", "usage.heatmap.tooltipRequests": "{requests} istek", "nav.storage": "Depolama", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9e154fa1b2f..19f1e50ce27 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -791,6 +791,8 @@ export const zhTW: Record = { "usage.dayWed": "三", "usage.dayFri": "五", "usage.heatmap.tooltipTokens": "{tokens} Token", + "usage.chart.dayDetail": "{date}:{requests} 個請求,{tokens} 個 Token", + "usage.heatmap.keyboardLabel": "使用上下方向鍵按天移動,使用左右方向鍵按週移動。", "usage.heatmap.tooltipRequests": "{requests} 請求", "nav.storage": "儲存", "storage.title": "儲存", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 018f6e251bc..e081d027f3f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1842,6 +1842,8 @@ export const zh: Record = { "usage.dayWed": "三", "usage.dayFri": "五", "usage.heatmap.tooltipTokens": "{tokens} 令牌", + "usage.chart.dayDetail": "{date}:{requests} 个请求,{tokens} 个 Token", + "usage.heatmap.keyboardLabel": "使用上下方向键按天移动,使用左右方向键按周移动。", "usage.heatmap.tooltipRequests": "{requests} 请求", "nav.storage": "存储", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 024e29626ef..30dbc41a1bd 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -3,6 +3,8 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import { LanguageProvider } from "./i18n/provider"; import "./styles.css"; +import "./styles/usage-chart-accessibility.css"; +import "./styles/sidebar-brand.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 900ab6eac02..1b655f8cc91 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import type { UsageReadMetadata } from "../usage-summary-resource"; import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; @@ -141,6 +142,52 @@ function lastSevenDays(days: UsageDay[]): UsageDay[] { return out; } +function formatCalendarDate(date: string, locale: Locale): string { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(`${date}T12:00:00`)); +} + +function chartTipPosition(rect: DOMRect): CSSProperties { + const gutter = 8; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const maxWidth = Math.min(240, Math.max(0, viewportWidth - gutter * 2)); + const left = Math.max(gutter, Math.min(rect.left + rect.width / 2 - maxWidth / 2, viewportWidth - gutter - maxWidth)); + const above = rect.top - gutter > viewportHeight - rect.bottom - gutter; + const vertical = above + ? (() => { + const bottom = Math.max(gutter, Math.min(viewportHeight - gutter, viewportHeight - rect.top + gutter)); + return { bottom, maxHeight: Math.max(0, viewportHeight - bottom - gutter) }; + })() + : (() => { + const top = Math.max(gutter, Math.min(viewportHeight - gutter, rect.bottom + gutter)); + return { top, maxHeight: Math.max(0, viewportHeight - top - gutter) }; + })(); + return { left, maxWidth, ...vertical }; +} + +function UsageChartOverlay({ + anchor, + className, + children, +}: { + anchor: DOMRect; + className: string; + children: ReactNode; +}) { + return createPortal( +
{children}
, + document.body, + ); +} + +function dayDetail(day: Pick, locale: Locale, t: TFn): string { + return t("usage.chart.dayDetail", { + date: formatCalendarDate(day.date, locale), + requests: day.requests, + tokens: formatTokens(day.totalTokens, locale), + }); +} + function quantileBuckets(values: number[]): number[] { const positive = values.filter(v => v > 0).sort((a, b) => a - b); if (positive.length === 0) return [0, 0, 0, 0]; @@ -360,20 +407,33 @@ function UsageSummaryCards({ } function WeekDayBars({ weekBars, locale, t }: { weekBars: UsageDay[]; locale: Locale; t: TFn }) { - const [hoverDay, setHoverDay] = useState(null); + const [active, setActive] = useState<{ date: string; anchor: DOMRect } | null>(null); const max = Math.max(1, ...weekBars.map(day => day.totalTokens)); + const activeDay = weekBars.find(day => day.date === active?.date); + const show = (day: UsageDay, element: HTMLElement) => { + setActive({ date: day.date, anchor: element.getBoundingClientRect() }); + }; return ( -
+
{weekBars.map(day => { const percentage = Math.round((day.totalTokens / max) * 100); - const label = day.date.slice(5); + const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(new Date(`${day.date}T12:00:00`)); return ( -
setHoverDay(day.date)} - onMouseLeave={() => setHoverDay(current => (current === day.date ? null : current))} + aria-label={dayDetail(day, locale, t)} + onFocus={event => show(day, event.currentTarget)} + onBlur={() => setActive(current => current?.date === day.date ? null : current)} + onPointerEnter={event => show(day, event.currentTarget)} + onPointerDown={event => show(day, event.currentTarget)} + onPointerLeave={event => { + if (event.pointerType !== "touch" && document.activeElement !== event.currentTarget) { + setActive(current => current?.date === day.date ? null : current); + } + }} >
- {hoverDay === day.date && day.totalTokens > 0 && ( -
-
{day.date}
- {day.models.slice(0, 8).map(model => ( -
- - {modelLabel(model.model)} - {formatTokens(model.totalTokens, locale)} -
- ))} -
- )} {formatTokens(day.totalTokens, locale)} {label} -
+ ); })} + {active && activeDay && ( + +
{formatCalendarDate(activeDay.date, locale)}
+
+ {t("usage.heatmap.tooltipRequests", { requests: activeDay.requests })} + {t("usage.heatmap.tooltipTokens", { tokens: formatTokens(activeDay.totalTokens, locale) })} +
+ {activeDay.models.slice(0, 8).map(model => ( +
+ + {modelLabel(model.model)} + {formatTokens(model.totalTokens, locale)} +
+ ))} +
+ )}
); } @@ -427,7 +491,34 @@ function UsageHeatmapPanel({ t: TFn; }) { const heatmapRef = useRef(null); - const [hoverCell, setHoverCell] = useState<{ weekIndex: number; dayIndex: number; x: number; y: number } | null>(null); + const cells = useMemo(() => heatmap.weeks.flat().filter(cell => cell.date), [heatmap]); + const [selectedDate, setSelectedDate] = useState(() => cells.at(-1)?.date ?? ""); + const [tip, setTip] = useState<{ date: string; anchor: DOMRect } | null>(null); + const hintId = useId(); + const rovingDate = cells.some(cell => cell.date === selectedDate) ? selectedDate : (cells.at(-1)?.date ?? ""); + + const selectCell = (cell: HeatmapCell, element: HTMLElement) => { + setSelectedDate(cell.date); + setTip({ date: cell.date, anchor: element.getBoundingClientRect() }); + }; + + const onCellKeyDown = (event: React.KeyboardEvent, cell: HeatmapCell) => { + const index = cells.findIndex(candidate => candidate.date === cell.date); + const offset = event.key === "ArrowUp" ? -1 + : event.key === "ArrowDown" ? 1 + : event.key === "ArrowLeft" ? -7 + : event.key === "ArrowRight" ? 7 + : 0; + if (!offset || index < 0) return; + event.preventDefault(); + const next = cells[Math.max(0, Math.min(cells.length - 1, index + offset))]!; + setSelectedDate(next.date); + const element = heatmapRef.current?.querySelector(`[data-date="${next.date}"]`); + if (element) { + element.focus(); + setTip({ date: next.date, anchor: element.getBoundingClientRect() }); + } + }; useEffect(() => { const element = heatmapRef.current; @@ -445,7 +536,7 @@ function UsageHeatmapPanel({ {range === "7d" ? ( ) : ( -
+
{heatmap.months.map(month => ( @@ -456,36 +547,54 @@ function UsageHeatmapPanel({
{t("usage.dayMon")}{t("usage.dayWed")}{t("usage.dayFri")}
-
+
{heatmap.weeks.map((week, weekIndex) => (
- {week.map((cell, dayIndex) => ( -
cell.date ? ( +
))}
- {hoverCell && (() => { - const cell = heatmap.weeks[hoverCell.weekIndex]?.[hoverCell.dayIndex]; + {t("usage.heatmap.keyboardLabel")} + + {cells.find(cell => cell.date === rovingDate) ? dayDetail(cells.find(cell => cell.date === rovingDate)!, locale, t) : ""} + + {tip && (() => { + const cell = cells.find(candidate => candidate.date === tip.date); if (!cell?.date) return null; return ( -
-
{cell.date}
+ +
{formatCalendarDate(cell.date, locale)}
{t("usage.heatmap.tooltipTokens", { tokens: formatTokens(cell.totalTokens, locale) })}
{t("usage.heatmap.tooltipRequests", { requests: cell.requests })}
-
+ ); })()}
diff --git a/gui/src/styles.css b/gui/src/styles.css index 352d1c53a2d..28c9277c1ed 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2592,7 +2592,7 @@ button.prov-account-row.active { cursor: default; } .heatmap-cell-4 { background: var(--green); } .heatmap-legend { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-label); align-self: flex-end; position: sticky; right: 0; } .heatmap-legend .heatmap-cell { width: 10px; height: 10px; } -.heatmap-tip { position: fixed; z-index: 10; transform: translate(-50%, -100%) translateY(-8px); pointer-events: none; +.heatmap-tip { position: fixed; z-index: var(--z-popover); pointer-events: none; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 10px; box-shadow: var(--shadow-sm); white-space: nowrap; font-size: var(--text-label); } .heatmap-tip-date { font-weight: var(--weight-semibold); color: var(--text); margin-bottom: 2px; } @@ -2603,7 +2603,7 @@ button.prov-account-row.active { cursor: default; } /* 7d view: per-day request bar chart (replaces the year heatmap on the 7d toggle). */ .daybars { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; align-items: end; height: 180px; padding-top: 8px; } -.daybar { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; } +.daybar { appearance: none; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; } .daybar-track { width: 100%; max-width: 48px; flex: 1; display: flex; align-items: flex-end; background: var(--border); border-radius: var(--radius-xs); overflow: hidden; } /* Full-height stack scaled on Y — avoids layout thrash from animating height. */ .daybar-stack { @@ -2622,9 +2622,9 @@ button.prov-account-row.active { cursor: default; } .daybar-count { font-size: var(--text-label); font-weight: var(--weight-semibold); color: var(--text); } .daybar-label { font-size: var(--text-caption); white-space: nowrap; } .daybar:hover .daybar-track { outline: 1px solid var(--border); } -.daybar-tip { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); z-index: 5; +.daybar-tip { position: fixed; z-index: var(--z-popover); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; - min-width: 160px; box-shadow: var(--shadow-sm); pointer-events: none; } + min-width: min(160px, calc(100vw - 16px)); box-shadow: var(--shadow-sm); pointer-events: none; } .daybar-tip-date { font-size: var(--text-label); font-weight: var(--weight-semibold); color: var(--text); margin-bottom: 6px; white-space: nowrap; } .daybar-tip-row { display: flex; align-items: center; gap: 8px; font-size: var(--text-label); line-height: var(--leading-relaxed); } .daybar-tip-swatch { width: 10px; height: 10px; border-radius: var(--radius-2xs); flex-shrink: 0; } diff --git a/gui/src/styles/sidebar-brand.css b/gui/src/styles/sidebar-brand.css new file mode 100644 index 00000000000..f5ff00bce55 --- /dev/null +++ b/gui/src/styles/sidebar-brand.css @@ -0,0 +1,18 @@ +/* The sidebar is also the mobile drawer's full-version fallback. Keep its badge + readable instead of applying the compact topbar's ellipsis policy here. + A smaller column gap fits release versions without widening the rail; longer + versions move to another line, and very long build ids wrap within the badge. */ +.drawer-head .brand { + flex-wrap: wrap; + column-gap: var(--space-1-5); + row-gap: var(--space-1); +} + +.drawer-head .brand .ver { + flex: 0 0 auto; + max-width: 100%; + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; +} diff --git a/gui/src/styles/usage-chart-accessibility.css b/gui/src/styles/usage-chart-accessibility.css new file mode 100644 index 00000000000..caa86736e3c --- /dev/null +++ b/gui/src/styles/usage-chart-accessibility.css @@ -0,0 +1,4 @@ +/* Usage chart controls retain chart styling while exposing keyboard focus. */ +.heatmap-grid button.heatmap-cell { appearance: none; border: 0; padding: 0; cursor: pointer; } +.heatmap-grid button.heatmap-cell:focus-visible, .daybar:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.chart-overlay { box-sizing: border-box; overflow: hidden; white-space: normal; overflow-wrap: anywhere; } diff --git a/gui/tests/sidebar-version-browser.ts b/gui/tests/sidebar-version-browser.ts new file mode 100644 index 00000000000..59e4b01c433 --- /dev/null +++ b/gui/tests/sidebar-version-browser.ts @@ -0,0 +1,140 @@ +/** Built-CSS geometry regression. Run after `bun run build` with CHROME_BIN set + * when Chrome/Chromium is not on PATH. No browser package or downloads required. + * The fixture uses the real bundled stylesheet and the App drawer/topbar markup; + * it intentionally does not connect to a user's proxy or credentials. */ +import { mkdtemp, readFile, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; + +const gui = resolve(import.meta.dir, ".."); +const dist = join(gui, "dist"); +const output = resolve(process.argv[2] ?? join(gui, ".tmp/sidebar-version-browser")); +const chrome = process.env.CHROME_BIN || ["chromium", "chromium-browser", "google-chrome", "chrome"] + .map(name => Bun.which(name)).find(Boolean); +if (!chrome) throw new Error("Set CHROME_BIN to Chrome/Chromium, then run bun run test:sidebar-version."); +const index = await readFile(join(dist, "index.html"), "utf8"); +const cssPath = index.match(/]*href="([^"]+\.css)"/)?.[1]; +if (!cssPath) throw new Error("Build the GUI first: bun run build."); +const cssFile = resolve(dist, cssPath.replace(/^\/+/, "")); +if (!cssFile.startsWith(`${dist}${sep}`)) throw new Error("Built CSS must stay inside gui/dist."); +const css = await readFile(cssFile, "utf8"); +const logo = `data:image/png;base64,${(await readFile(join(dist, "logo.png"))).toString("base64")}`; +const brand = ``; +const html = `
${brand}
`; +const profile = await mkdtemp(join(tmpdir(), "ocx-sidebar-chrome-")); +const browser = Bun.spawn([chrome, "--headless", "--disable-gpu", "--disable-background-networking", + "--no-first-run", "--no-default-browser-check", "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", `--user-data-dir=${profile}`, + ...(process.env.CHROME_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), "about:blank"], +{ stdout: "ignore", stderr: "ignore" }); +let socket: WebSocket | undefined; +const delay = (ms: number) => new Promise(done => setTimeout(done, ms)); +try { + let debugPort = ""; + const deadline = Date.now() + 10_000; + while (!debugPort && Date.now() < deadline) { + try { debugPort = (await readFile(join(profile, "DevToolsActivePort"), "utf8")).split("\n")[0]; } + catch { await delay(50); } + } + if (!/^\d+$/.test(debugPort)) throw new Error("Chrome did not expose its local debugging port within 10 seconds."); + const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: "PUT", signal: AbortSignal.timeout(5_000) }); + if (!response.ok) throw new Error(`Cannot create browser target: ${response.status}`); + const target = await response.json() as { webSocketDebuggerUrl: string }; + socket = new WebSocket(target.webSocketDebuggerUrl); + const ws = socket; + await new Promise((done, fail) => { + const timer = setTimeout(() => fail(new Error("CDP connection timed out")), 5_000); + ws.addEventListener("open", () => { clearTimeout(timer); done(); }, { once: true }); + ws.addEventListener("error", () => { clearTimeout(timer); fail(new Error("CDP connection failed")); }, { once: true }); + }); + let id = 0; + const pending = new Map void; reject: (reason: Error) => void }>(); + ws.addEventListener("message", event => { + const message = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } }; + if (message.id === undefined) return; + const call = pending.get(message.id); + if (!call) return; + pending.delete(message.id); + if (message.error) call.reject(new Error(message.error.message)); else call.resolve(message.result); + }); + function cdp(method: string, params: Record = {}): Promise { + return new Promise((done, fail) => { + const next = ++id; + const timer = setTimeout(() => { pending.delete(next); fail(new Error(`CDP timeout: ${method}`)); }, 5_000); + pending.set(next, { resolve: value => { clearTimeout(timer); done(value as T); }, reject: error => { clearTimeout(timer); fail(error); } }); + ws.send(JSON.stringify({ id: next, method, params })); + }); + } + async function evaluate(expression: string): Promise { + const result = await cdp<{ result: { value: T }; exceptionDetails?: unknown }>("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(`Browser evaluation failed: ${JSON.stringify(result.exceptionDetails)}`); + return result.result.value; + } + await cdp("Page.enable"); + // Offline document: no proxy, management API, external assets or browser navigation. + const { frameTree } = await cdp<{ frameTree: { frame: { id: string } } }>("Page.getFrameTree"); + await cdp("Page.setDocumentContent", { frameId: frameTree.frame.id, html }); + for (let attempt = 0; attempt < 100; attempt++) { + if (await evaluate('document.readyState === "complete" && !!document.querySelector(".drawer-head .ver")')) break; + if (attempt === 99) throw new Error(`Built-CSS fixture did not finish loading: ${await evaluate('JSON.stringify({url:location.href,state:document.readyState,html:document.body?.innerHTML.slice(0,500)})')}`); + await delay(25); + } + const cases: unknown[] = []; + const versions = ["2.56.0", "2.57.0", "2.56.0-beta.1", `2.56.0-preview.20260916+${"a".repeat(64)}`]; + await mkdir(output, { recursive: true }); + for (const theme of ["light", "dark"]) for (const width of [320, 360, 375, 414, 760, 761, 1024, 1920]) { + await cdp("Emulation.setDeviceMetricsOverride", { width, height: 800, deviceScaleFactor: 1, mobile: false }); + for (const version of versions) for (const wideFont of [false, true]) { + await evaluate(`(() => { + document.documentElement.dataset.theme = ${JSON.stringify(theme)}; + document.documentElement.style.setProperty("--text-subtitle", ${JSON.stringify(wideFont ? "18px" : "16px")}); + document.querySelectorAll(".ver").forEach(el => { el.textContent = ${JSON.stringify(`v${version}`)}; }); + })()`); + const geometry = await evaluate<{ ok: boolean; [key: string]: unknown }>(`(() => { + const box = el => { const r = el.getBoundingClientRect(); return { left:r.left, right:r.right, top:r.top, bottom:r.bottom, width:r.width, height:r.height }; }; + const brand = document.querySelector(".drawer-head .brand"); + const badge = brand.querySelector(".ver"); + const close = document.querySelector(".drawer-close"); + const b=box(badge), h=box(brand), c=box(close), n=box(brand.querySelector(".name")); + const range = document.createRange(); range.selectNodeContents(badge); + const text = [...range.getClientRects()].map(r => ({left:r.left,right:r.right,top:r.top,bottom:r.bottom})); + const visible = b.width > 0 && b.height > 0 && text.length > 0; + const bounded = b.left >= h.left - .5 && b.right <= h.right + .5; + const complete = badge.scrollWidth <= badge.clientWidth + 1 && text.every(r => r.left >= b.left - .5 && r.right <= b.right + .5 && r.top >= b.top - .5 && r.bottom <= b.bottom + .5); + const overlapsClose = c.width > 0 && b.left < c.right && b.right > c.left && b.top < c.bottom && b.bottom > c.top; + const style = getComputedStyle(badge); + const shortRelease = /^v\\d+\\.\\d+\\.\\d+$/.test(badge.textContent); + const headerStyle = getComputedStyle(brand); + const logo = box(brand.querySelector(".brand-logo")); + const contentWidth = h.width - parseFloat(headerStyle.paddingLeft) - parseFloat(headerStyle.paddingRight); + const requiredWidth = logo.width + n.width + b.width + 2 * parseFloat(headerStyle.columnGap); + // Font fallbacks differ by OS. Wrapping the whole badge when the row is + // genuinely full is intended; clipping its text or splitting a short + // version is not. Require the same row only when all three items fit. + const singleLine = !shortRelease || text.length === 1; + const rowFits = requiredWidth <= contentWidth + .5; + const sameRowWhenPossible = !shortRelease || !rowFits || (b.top < n.bottom && b.bottom > n.top); + return { ok: visible && bounded && complete && !overlapsClose && singleLine && sameRowWhenPossible, badge:b, brand:h, name:n, close:c, text, overlapsClose, bounded, complete, singleLine, sameRowWhenPossible, rowFits, requiredWidth, contentWidth, font:headerStyle.fontFamily, overflow:style.textOverflow, value:badge.textContent }; + })()`); + const row = { theme, width, version, wideFont, ...geometry }; + cases.push(row); + if (!geometry.ok) { + await writeFile(join(output, "failure.json"), JSON.stringify(row, null, 2)); + throw new Error(`Sidebar geometry regression: ${JSON.stringify(row)}`); + } + if (theme === "dark" && width === 1024 && version === "2.56.0" && wideFont) { + const image = await cdp<{ data: string }>("Page.captureScreenshot", { format: "png", clip: { x:0, y:0, width:232, height:110, scale:1 } }); + await writeFile(join(output, "sidebar-built-css.png"), Buffer.from(image.data, "base64")); + } + } + } + const version = await cdp("Browser.getVersion"); + await writeFile(join(output, "results.json"), JSON.stringify({ scope: "Real Chromium geometry with built production CSS; isolated App header markup, no live proxy", browser: version, cssPath, cssSha256: new Bun.CryptoHasher("sha256").update(css).digest("hex"), cases }, null, 2)); + console.log(`PASS: ${cases.length} built-CSS browser cases; full version visible, badge bounded, no drawer-close overlap.`); +} finally { + socket?.close(); + browser.kill(); + await Promise.race([browser.exited, delay(2_000)]); + if (browser.exitCode === null) { browser.kill("SIGKILL"); await browser.exited; } + await rm(profile, { recursive: true, force: true }); +} diff --git a/gui/tests/sidebar-version-layout.test.ts b/gui/tests/sidebar-version-layout.test.ts new file mode 100644 index 00000000000..6be645179e4 --- /dev/null +++ b/gui/tests/sidebar-version-layout.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; + +const css = await Bun.file(new URL("../src/styles/sidebar-brand.css", import.meta.url)).text(); +const entry = await Bun.file(new URL("../src/main.tsx", import.meta.url)).text(); + +function block(selector: string): string { + const start = css.indexOf(`${selector} {`); + if (start < 0) throw new Error(`selector not found: ${selector}`); + return css.slice(start, css.indexOf("}", start)); +} + +// These source guards complement browser geometry checks: merely removing the +// ellipsis lets a long version paint over the drawer close control. The header +// must wrap the badge, and the badge must also bound unbroken build identifiers. +test("the sidebar brand rules are loaded after the shared stylesheet", () => { + const shared = entry.indexOf('import "./styles.css";'); + const sidebar = entry.indexOf('import "./styles/sidebar-brand.css";'); + expect(shared).toBeGreaterThan(-1); + expect(sidebar).toBeGreaterThan(shared); + expect(entry.match(/import "\.\/styles\/sidebar-brand\.css";/g)).toHaveLength(1); +}); + +test("the sidebar header wraps versions rather than squeezing the badge", () => { + const brand = block(".drawer-head .brand"); + expect(brand).toContain("flex-wrap: wrap"); + expect(brand).toContain("column-gap: var(--space-1-5)"); + expect(brand).toContain("row-gap: var(--space-1)"); + expect(block(".drawer-head .brand .ver")).toContain("flex: 0 0 auto"); +}); + +test("long prerelease and unbroken build identifiers stay inside the header", () => { + const badge = block(".drawer-head .brand .ver"); + expect(badge).toContain("max-width: 100%"); + expect(badge).toContain("white-space: normal"); + expect(badge).toContain("overflow-wrap: anywhere"); +}); + +test("the full-version fallback does not hide or ellipsize its text", () => { + const badge = block(".drawer-head .brand .ver"); + expect(badge).toContain("overflow: visible"); + expect(badge).toContain("text-overflow: clip"); + expect(badge).not.toContain("overflow: hidden"); + expect(badge).not.toContain("text-overflow: ellipsis"); + expect(badge).not.toContain("white-space: nowrap"); +}); + +test("the fix stays scoped to the drawer and leaves compact topbar policies intact", () => { + const selectors = [...css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/([^{}]+)\{/g)] + .map(match => match[1].trim()); + expect(selectors).toEqual([".drawer-head .brand", ".drawer-head .brand .ver"]); +}); diff --git a/gui/tests/usage-chart-interactions.test.tsx b/gui/tests/usage-chart-interactions.test.tsx new file mode 100644 index 00000000000..02f7392fe1b --- /dev/null +++ b/gui/tests/usage-chart-interactions.test.tsx @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import Usage from "../src/pages/Usage"; + +const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", "fetch", + "ResizeObserver", "IS_REACT_ACT_ENVIRONMENT", +] as const; + +let previous: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let win: Window; +let root: Root | null = null; + +function isoDay(offset = 0): string { + const day = new Date(); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() + offset); + return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, "0")}-${String(day.getDate()).padStart(2, "0")}`; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])) as typeof previous; + win = new Window({ url: "http://localhost/#dashboard" }); + class TestResizeObserver { + observe() {} + disconnect() {} + } + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + ResizeObserver: { configurable: true, value: TestResizeObserver }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + clearClientResourceStoresForTests(); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + clearClientResourceStoresForTests(); + win.close(); + for (const key of globals) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`timed out: ${document.body.innerHTML}`); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 10)); }); + } +} + +async function mount(node: React.ReactNode): Promise { + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + root = createRoot(container); + await act(async () => { root!.render({node}); }); + return container; +} + +function usagePayload(models: Array<{ provider: string; model: string; requests: number; totalTokens: number }> = []) { + const yesterday = isoDay(-1); + const today = isoDay(); + return { + range: "all", + surface: "all", + since: null, + generatedAt: Date.now(), + summary: { + requests: 7, measuredRequests: 7, reportedRequests: 7, unreportedRequests: 0, + unsupportedRequests: 0, estimatedRequests: 0, inputTokens: 70, outputTokens: 30, + cachedInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 100, coverageRatio: 1, + }, + days: [ + { date: yesterday, requests: 2, measuredRequests: 2, reportedRequests: 2, totalTokens: 25, models: [] }, + { date: today, requests: 5, measuredRequests: 5, reportedRequests: 5, totalTokens: 75, models }, + ], + models: [], providers: [], historyTruncated: false, truncatedPrefixBytes: 0, + entriesTruncated: false, entriesDropped: 0, + }; +} + +test("Usage heatmap exposes one roving entry and day/week keyboard movement", async () => { + globalThis.fetch = (async () => Response.json(usagePayload())) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + + expect(container.querySelector(".heatmap-grid")?.getAttribute("role")).toBe("group"); + expect(container.querySelectorAll(".heatmap-grid [role='gridcell']")).toHaveLength(0); + expect(container.querySelectorAll(".heatmap-grid [tabindex='0']")).toHaveLength(1); + const initial = container.querySelector(".heatmap-grid [tabindex='0']")!; + expect(initial.tagName).toBe("BUTTON"); + const initialDate = initial.dataset.date!; + await act(async () => { initial.focus(); }); + const heatmapTip = document.querySelector(".heatmap-tip"); + expect(heatmapTip?.textContent).toContain("requests"); + expect(heatmapTip?.parentNode === document.body).toBe(true); + expect(container.contains(heatmapTip)).toBe(false); + + await act(async () => { + initial.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true })); + await new Promise(resolve => win.setTimeout(resolve, 0)); + }); + const previousDay = container.querySelector(".heatmap-grid [tabindex='0']")!; + expect(previousDay.dataset.date).not.toBe(initialDate); + expect(document.activeElement).toBe(previousDay); + + await act(async () => { + previousDay.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true })); + await new Promise(resolve => win.setTimeout(resolve, 0)); + }); + expect(container.querySelector(".heatmap-grid [tabindex='0']")?.dataset.date).not.toBe(previousDay.dataset.date); +}); + +test("seven-day bars expose the same detail on focus and touch", async () => { + globalThis.fetch = (async () => Response.json(usagePayload())) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + const sevenDay = Array.from(container.querySelectorAll(".usage-segmented-btn")) + .find(button => button.textContent === "7d")!; + await act(async () => { sevenDay.click(); }); + await waitFor(() => container.querySelectorAll(".daybar").length === 7); + + const bars = container.querySelectorAll(".daybar"); + expect(bars).toHaveLength(7); + expect(Array.from(bars).every(bar => bar.tabIndex === 0)).toBe(true); + + const today = bars[6]!; + await act(async () => { today.focus(); }); + const focused = document.querySelector(".daybar-tip")?.textContent; + expect(focused).toContain("5 requests"); + expect(focused).toContain("75 tokens"); + + await act(async () => { today.blur(); }); + await act(async () => { + today.dispatchEvent(new win.PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" })); + }); + expect(document.querySelector(".daybar-tip")?.textContent).toBe(focused); +}); + +test("Usage tooltip portals stay inside viewport gutters at the lower-right edge", async () => { + Object.defineProperties(win, { + innerWidth: { configurable: true, value: 320 }, + innerHeight: { configurable: true, value: 240 }, + }); + const models = Array.from({ length: 8 }, (_, index) => ({ + provider: "openai", + model: `model-${index}`, + requests: 1, + totalTokens: index + 1, + })); + globalThis.fetch = (async () => Response.json(usagePayload(models))) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + const sevenDay = Array.from(container.querySelectorAll(".usage-segmented-btn")) + .find(button => button.textContent === "7d")!; + await act(async () => { sevenDay.click(); }); + await waitFor(() => container.querySelectorAll(".daybar").length === 7); + + const today = container.querySelectorAll(".daybar")[6]!; + Object.defineProperty(today, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 220, right: 320, bottom: 240, left: 300, width: 20, height: 20, x: 300, y: 220, toJSON() {} }), + }); + await act(async () => { today.focus(); }); + + const tooltip = document.querySelector(".daybar-tip")!; + expect(tooltip.parentNode === document.body).toBe(true); + expect(container.contains(tooltip)).toBe(false); + expect(tooltip.querySelectorAll(".daybar-tip-row")).toHaveLength(9); + expect(parseFloat(tooltip.style.left)).toBeGreaterThanOrEqual(8); + expect(parseFloat(tooltip.style.left) + parseFloat(tooltip.style.maxWidth)).toBeLessThanOrEqual(312); + expect(parseFloat(tooltip.style.bottom)).toBeGreaterThanOrEqual(8); + expect(parseFloat(tooltip.style.maxHeight) + parseFloat(tooltip.style.bottom)).toBeLessThanOrEqual(232); +}); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 78a3e9b1655..3ef6373ccfe 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -221,9 +221,9 @@ test("America/Santiago midnight DST retains final-day activity and tooltip", asy await act(async () => gate.resolve(Response.json(data))); const active = container.querySelector('.heatmap-grid .heatmap-cell:not(.heatmap-cell-0)'); expect(active).not.toBeNull(); - await act(async () => active!.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true }))); - expect(container.querySelector(".heatmap-tip-date")?.textContent).toBe("2026-09-07"); - expect(container.querySelector(".heatmap-tip")?.textContent).toContain("700"); + await act(async () => active!.dispatchEvent(new testWindow.PointerEvent("pointerover", { bubbles: true }))); + expect(document.querySelector(".heatmap-tip-date")?.textContent).toBe("Sep 7, 2026"); + expect(document.querySelector(".heatmap-tip")?.textContent).toContain("700"); if (process.env.OCX_USAGE_SANTIAGO_CHILD === "1") console.log("OCX_SANTIAGO_CASE_COMPLETED"); }, process.env.OCX_USAGE_SANTIAGO_CHILD === "1" ? 10000 : 15000); @@ -254,8 +254,8 @@ test("Apply submits inclusive bounds once; Clear restores the held preset withou // A one-day historical window must not produce a year grid anchored to today's date. expect(container.querySelectorAll(".heatmap-grid .heatmap-cell")).toHaveLength(7); const activeCell = container.querySelector(".heatmap-grid .heatmap-cell-1")!; - await act(async () => { activeCell.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true })); }); - expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("2020-09-15"); + await act(async () => { activeCell.dispatchEvent(new testWindow.PointerEvent("pointerover", { bubbles: true })); }); + expect(document.querySelector('[role="tooltip"]')?.textContent).toContain("Sep 15, 2020"); await enter("2020-09-16T10:20", "2020-09-16T10:21"); expect(interval()).toBe(appliedInterval); expect(requests).toHaveLength(2); diff --git a/package.json b/package.json index 6d595e1312e..22449e507ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.56.0", + "version": "2.57.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", @@ -76,11 +76,11 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.4.2", + "bun": "1.4.0", "zod": "4.4.3" }, "devDependencies": { - "@types/bun": "1.4.2", + "@types/bun": "1.4.0", "typescript": "7.0.2" }, "overrides": { diff --git a/scripts/ci/bun-crash-signatures.sh b/scripts/ci/bun-crash-signatures.sh new file mode 100755 index 00000000000..476f6539c6f --- /dev/null +++ b/scripts/ci/bun-crash-signatures.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# The single definition of "this was a Bun runtime crash, not a test result". +# +# There were four copies of this list: one in the Linux batch runner and three inline in ci.yml +# (platform-windows, platform-macos, macos-control). ci-workflows.test.ts pinned them in sync +# rather than removing the duplication, because #2152 had already broken one copy by anchoring on +# `panic(thread 2852)` when Bun also emits `panic(main thread)` for the same class, and half the +# crashes stopped matching. Pinning four copies in sync only detects the drift it was written to +# expect; one definition cannot drift at all. +# +# Source it from the repository root: source scripts/ci/bun-crash-signatures.sh + +# shellcheck shell=bash + +# Sourced by nested shells in the same job, so a second source must be a no-op rather than a +# readonly-reassignment error. +if [[ -n "${OCX_BUN_CRASH_SIGNATURES_LOADED:-}" ]]; then + return 0 +fi +OCX_BUN_CRASH_SIGNATURES_LOADED=1 + +# Never anchor on the thread-numbered form. `Internal assertion failure` is the stable fingerprint +# recorded in devlog/_fin/260731_pr_issue_triage_round/050_windows_ci_flake_rca.md. +OCX_BUN_CRASH_SIGNATURE_PATTERN='oh no: Bun has crashed|Internal assertion failure|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' + +# True when the process output carries a Bun panic banner. +bun_log_has_crash_signature() { + grep -Eqi "$OCX_BUN_CRASH_SIGNATURE_PATTERN" "$1" +} + +# True for a status that can only be a fatal signal. +# +# 128+N for SIGILL/SIGABRT/SIGBUS/SIGKILL/SIGSEGV and neighbours. Windows exit 3 is deliberately +# NOT in this list: it is the status a Windows Bun returns alongside a SIGSEGV panic, but unlike +# 132-139 it is an ordinary small exit code any process may return for its own reasons. Trusting +# it bare would reclassify a real test failure as a crash and hide it, which is the exact mistake +# this file exists to stop. Exit 3 is still covered, through the signature arm below, which is +# corroborated by the panic banner Bun actually printed -- that is how the Windows shard 5/6 +# crashes of runs 35087572377, 35093667426 and 35098735960 are recognised. +bun_status_is_crash_code() { + case "$1" in + 132|133|134|135|136|137|139) return 0 ;; + esac + return 1 +} + +# The shared predicate: is_bun_runtime_crash +is_bun_runtime_crash() { + local status="$1" + local log_file="$2" + + if bun_status_is_crash_code "$status"; then + return 0 + fi + + # Bun 1.3.14 can surface a Linux epoll registration failure as exit 1, even though the failure + # comes from Bun's internal WriteStream setup rather than a test assertion. Treat only that + # narrow runtime signature as a crash. + if [[ "$status" == "1" ]] \ + && grep -Fq '# Unhandled error between tests' "$log_file" \ + && grep -Fq 'error: EEXIST: file already exists, epoll_ctl' "$log_file" \ + && grep -Fq 'at new WriteStream (internal:fs/streams:' "$log_file"; then + return 0 + fi + + bun_log_has_crash_signature "$log_file" +} + diff --git a/scripts/ci/run-bun-test-batches.sh b/scripts/ci/run-bun-test-batches.sh index ae34310258f..147c87c9804 100644 --- a/scripts/ci/run-bun-test-batches.sh +++ b/scripts/ci/run-bun-test-batches.sh @@ -11,6 +11,10 @@ readonly BATCH_KILL_GRACE_SECONDS="${BUN_TEST_BATCH_KILL_GRACE_SECONDS:-15}" # bundled stable runtime anyway, and report a qualification it never performed. readonly BUN_BIN="${OPENCODEX_BUN_PATH:-bun}" +# One definition of the crash classifier, shared with the Windows and macOS legs in ci.yml. +# shellcheck source=scripts/ci/bun-crash-signatures.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bun-crash-signatures.sh" + usage() { echo "usage: $0 " >&2 exit 64 @@ -64,31 +68,6 @@ is_general_test_file() { esac } -is_bun_runtime_crash() { - local status="$1" - local log_file="$2" - - case "$status" in - 132|133|134|135|136|137|139) - return 0 - ;; - esac - - # Bun 1.3.14 can surface a Linux epoll registration failure as exit 1, - # even though the failure comes from Bun's internal WriteStream setup rather - # than a test assertion. Treat only that narrow runtime signature as a crash. - if (( status == 1 )) \ - && grep -Fq '# Unhandled error between tests' "$log_file" \ - && grep -Fq 'error: EEXIST: file already exists, epoll_ctl' "$log_file" \ - && grep -Fq 'at new WriteStream (internal:fs/streams:' "$log_file"; then - return 0 - fi - - grep -Eqi \ - 'oh no: Bun has crashed|Internal assertion failure|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' \ - "$log_file" -} - LAST_FAILURE_KIND="" run_test_once() { @@ -189,7 +168,14 @@ recover_batch_file_by_file() { return "$status" done - echo "::warning::Shard ${SHARD_SPEC} batch ${batch_number} passed under singleton isolation after the original ${batch_failure_kind}; continuing." + if [[ "$batch_failure_kind" == "runtime" ]]; then + # Not "recovered". One file per process is a configuration in which this class of defect + # cannot occur, so the sweep was always going to pass and always going to report nothing. + # What it does prove is that the files themselves are sound, which is the half worth keeping. + echo "::error::Shard ${SHARD_SPEC} batch ${batch_number} crashed the Bun runtime. Every file in it then passed alone, so the defect is in multi-file process state, not in any test." + else + echo "::warning::Shard ${SHARD_SPEC} batch ${batch_number} passed under singleton isolation after the original ${batch_failure_kind}; continuing." + fi return 0 } @@ -218,7 +204,12 @@ fi readonly TOTAL_BATCHES=$(( (${#SELECTED_FILES[@]} + BATCH_SIZE - 1) / BATCH_SIZE )) echo "Shard ${SHARD_SPEC}: ${#SELECTED_FILES[@]} files in ${TOTAL_BATCHES} primary Bun processes (batch size <= ${BATCH_SIZE}, timeout ${BATCH_TIMEOUT_SECONDS}s)." -echo "Runtime crashes and timeouts fall back to one-file-per-process isolation; assertion/test failures do not retry." +echo "Timeouts fall back to one-file-per-process isolation and may recover; assertion/test failures do not retry." +echo "A Bun runtime crash is swept one-file-per-process for attribution and then FAILS this shard: it is a defect in the interpreter, and a green report would be a lie." + +# Every batch that crashed the runtime, so one run attributes all of them instead of only the +# first. Linux was producing twelve to fourteen of these per run while reporting success. +CRASHED_BATCHES=() for ((batch_index = 0; batch_index < TOTAL_BATCHES; batch_index += 1)); do start=$(( batch_index * BATCH_SIZE )) @@ -237,8 +228,22 @@ for ((batch_index = 0; batch_index < TOTAL_BATCHES; batch_index += 1)); do failure_kind="$LAST_FAILURE_KIND" if recover_batch_file_by_file "$batch_number" "$failure_kind" "${batch[@]}"; then - continue + recovery_status=0 else - exit $? + recovery_status=$? + fi + + if [[ "$failure_kind" == "runtime" ]]; then + CRASHED_BATCHES+=("$batch_number") + fi + + # A sweep that found a real failing file still reports that file, and immediately. + if (( recovery_status != 0 )); then + exit "$recovery_status" fi done + +if (( ${#CRASHED_BATCHES[@]} > 0 )); then + echo "::error::Shard ${SHARD_SPEC} crashed the Bun runtime in batch(es): ${CRASHED_BATCHES[*]}. Each batch was re-run one file per process and every file passed, so no test is at fault -- the interpreter is. Failing rather than reporting green." + exit 1 +fi diff --git a/scripts/file-size-ratchet.ts b/scripts/file-size-ratchet.ts index d524aff7c5c..6c017a86811 100644 --- a/scripts/file-size-ratchet.ts +++ b/scripts/file-size-ratchet.ts @@ -28,9 +28,26 @@ export const EXCLUDED_PREFIXES = [ export const EXCLUDED_EXACT = new Set(["bun.lock", "gui/dist"]); +/** + * Machine-generated output. Regenerating it is the only way it changes, so a line count is + * a fact about the generator rather than about anyone's editing habits. + * + * Exactly one file qualifies, and it says so on its first line + * (@generated by protoc-gen-es). The test beside this list reads that banner rather than + * trusting the name, because "generated" was doing no work here: eleven hand-maintained + * files sat on this list, and calling them generated is what invites the twelfth. + */ export const GENERATED_PATHS = [ - "scripts/model-metadata.source.json", "src/adapters/cursor/gen/agent_pb.ts", +] as const; + +/** + * Translation catalogues. Hand-written, and exempt for a different reason: they grow by one + * line per UI string in nine locales at once, so a cap would block every new string in the + * GUI rather than any oversized module. gui/src/i18n/en.ts describes itself as the TKey + * source of truth; nothing generates these. + */ +export const I18N_CATALOG_PATHS = [ "gui/src/i18n/de.ts", "gui/src/i18n/en.ts", "gui/src/i18n/fr.ts", @@ -40,19 +57,37 @@ export const GENERATED_PATHS = [ "gui/src/i18n/tr.ts", "gui/src/i18n/zh.ts", "gui/src/i18n/zh-TW.ts", +] as const; + +/** + * Hand-maintained data snapshots. Records, not code: their size tracks how much was recorded, + * and splitting one would hide provenance rather than reduce complexity. + * + * model-metadata.source.json is the generator's INPUT, which is why naming it generated was + * backwards. Its output, src/generated/model-metadata.ts, is 108 lines and is scanned normally. + */ +export const DATA_SNAPSHOT_PATHS = [ "docs-site/src/data/frontier-benchmarks.json", + "scripts/model-metadata.source.json", ] as const; +/** Every path exempt from a size cap, whatever the reason. */ +export const EXEMPT_PATHS = [ + ...GENERATED_PATHS, + ...I18N_CATALOG_PATHS, + ...DATA_SNAPSHOT_PATHS, +].sort() as readonly string[]; + export type Verdict = | "NEW_OVERSIZED" | "GREW" | "SHRANK" - | "GENERATED" + | "EXEMPT" | "UNCHANGED" | "NEW_OK"; export type Baseline = { - generated: string[]; + exempt: string[]; files: Record; }; @@ -76,9 +111,9 @@ export function isScannedPath(path: string): boolean { } export function evaluate(files: FileSize[], baseline: Baseline): Evaluation[] { - const generated = new Set(baseline.generated); + const exempt = new Set(baseline.exempt); return files.map((file) => { - if (generated.has(file.path)) return { ...file, verdict: "GENERATED" }; + if (exempt.has(file.path)) return { ...file, verdict: "EXEMPT" }; const cap = baseline.files[file.path]; if (cap === undefined) { return { ...file, verdict: file.lines >= THRESHOLD ? "NEW_OVERSIZED" : "NEW_OK" }; @@ -115,11 +150,18 @@ export function scanRepo(repoRoot: string): FileSize[] { } export function loadBaseline(text: string): Baseline { - const parsed = JSON.parse(text) as Baseline; + const raw = JSON.parse(text) as Partial & { generated?: unknown }; + // "generated" was the field's name while it also held i18n catalogues and data snapshots. + // Reading it as exempt keeps a branch written before the rename loadable instead of + // failing with a shape error that says nothing about what changed. + const parsed = { + ...raw, + exempt: Array.isArray(raw.exempt) ? raw.exempt : raw.generated, + } as Baseline; if ( !parsed || typeof parsed !== "object" - || !Array.isArray(parsed.generated) + || !Array.isArray(parsed.exempt) || typeof parsed.files !== "object" || parsed.files === null || Array.isArray(parsed.files) @@ -144,13 +186,13 @@ export function updateBaseline(current: FileSize[], baseline: Baseline, seed: bo files[path] = Math.min(cap, lines); } if (seed) { - const generated = new Set(baseline.generated); + const exempt = new Set(baseline.exempt); for (const [path, lines] of now) { - if (generated.has(path) || lines < THRESHOLD || files[path] !== undefined) continue; + if (exempt.has(path) || lines < THRESHOLD || files[path] !== undefined) continue; files[path] = lines; } } - return { generated: [...baseline.generated], files: sortRecord(files) }; + return { exempt: [...baseline.exempt], files: sortRecord(files) }; } export function formatOffenders(rows: Evaluation[]): string { @@ -166,7 +208,7 @@ if (import.meta.main) { const existed = existsSync(baselinePath); const baseline: Baseline = existed ? loadBaseline(readFileSync(baselinePath, "utf8")) - : { generated: [...GENERATED_PATHS], files: {} }; + : { exempt: [...EXEMPT_PATHS], files: {} }; const current = scanRepo(repoRoot); if (process.argv.includes("--update")) { const next = updateBaseline(current, baseline, !existed); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 885d2abcd40..96e44b509cb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,7 +167,11 @@ } }, "explicit": { + "key-attribution.test.ts": "usage", "responses-core-modules.test.ts": "responses", + "responses-spend-ledger-wiring.test.ts": "responses", + "responses-send-budget-errors.test.ts": "responses", + "responses-4546-incident-regression.test.ts": "responses", "chat-responses-control-integration.test.ts": "responses", "coding-agent-tool-result-images.test.ts": "adapters", "hub-usage.test.ts": "server", @@ -262,6 +266,9 @@ "autostart-health.test.ts": "service", "azure-adapter.test.ts": "providers", "azure-model-router-tool-schema.test.ts": "providers", + "bare-echo-alias.test.ts": "responses", + "responses-bare-echo-helper-fence.test.ts": "responses", + "responses-preview-main-read-fence.test.ts": "responses", "baseten-provider.test.ts": "providers", "bearer-admission-routed-provider.test.ts": "codex-integration", "bounded-body.test.ts": "server", @@ -281,6 +288,7 @@ "cancel-body-on-abort.test.ts": "server", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-duplicate-slug-dedup.test.ts": "codex-integration", "catalog-free-pricing-status.test.ts": "codex-integration", "catalog-full-picker-order.test.ts": "codex-integration", "catalog-gated-native-suppression-reason.test.ts": "codex-integration", @@ -288,13 +296,16 @@ "catalog-hub-context-window.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", + "catalog-modelalias-unique-sync.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", "catalog-remote-pull.test.ts": "codex-integration", "catalog-retain-models.test.ts": "codex-integration", "catalog-seed-window-fill.test.ts": "codex-integration", + "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", + "chat-completions-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", @@ -308,6 +319,7 @@ "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", "chutes-provider.test.ts": "providers", + "ci-bun-crash-classifier.test.ts": "ci-workflows", "ci-workflows.test.ts": "ci-workflows", "citation-markers.test.ts": "responses", "cl01-claude-outbound-review-regressions.test.ts": "routing", @@ -417,6 +429,8 @@ "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", + "codex-account-selection-preferences.test.ts": "codex-integration", + "codex-account-store-refresh-classification.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", @@ -449,6 +463,7 @@ "codex-cooldown-recovery.test.ts": "codex-integration", "codex-coordinator-doctor.test.ts": "codex-integration", "codex-desired-state.test.ts": "codex-integration", + "codex-entitlement-identity-read-fence.test.ts": "codex-integration", "codex-envkey-admission-substitution.test.ts": "codex-integration", "codex-exec-invocation.test.ts": "codex-integration", "codex-features-cache.test.ts": "codex-integration", @@ -789,6 +804,7 @@ "key-login-preserves-model-costs.test.ts": "oauth", "keyring-smoke.test.ts": "ci-workflows", "kimi-oauth-identity.test.ts": "providers", + "kimi-responses-adjacency.test.ts": "providers", "kiro-account-quota.test.ts": "providers/kiro", "kiro-adapter.test.ts": "providers/kiro", "kiro-auth-context-continuation.test.ts": "providers/kiro", @@ -989,6 +1005,7 @@ "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "openai-api-virtual-models.test.ts": "adapters/openai", + "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", @@ -1048,6 +1065,7 @@ "privacy-mask-account.test.ts": "lib", "privacy-scan-meta-key.test.ts": "ci-workflows", "probe-lease.test.ts": "routing", + "probe-lease-dispatch-wiring.test.ts": "routing", "process-control-graceful.test.ts": "lib", "process-control.test.ts": "lib", "process-state.test.ts": "service", @@ -1234,12 +1252,14 @@ "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", + "server-combo-reasoning-replay-eligibility.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", + "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", @@ -1314,6 +1334,7 @@ "subagent-context-staleness.test.ts": "routing", "subagent-defaults.test.ts": "routing", "subagent-fallback-handle-responses.test.ts": "routing", + "subagent-fallback-preview-sites.test.ts": "routing", "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", "subagent-roster-retention.test.ts": "routing", @@ -1456,7 +1477,9 @@ "execution-budget-permits.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", "codex-pool-refresh-backoff.test.ts": "codex-integration", - "responses-account-change-scrub.test.ts": "responses" + "responses-account-change-scrub.test.ts": "responses", + "response-log-inspection.test.ts": "server", + "request-log-nonstream.test.ts": "usage" }, "migrated": [ "adapters", diff --git a/scripts/test-temp.ts b/scripts/test-temp.ts new file mode 100644 index 00000000000..3915fafffad --- /dev/null +++ b/scripts/test-temp.ts @@ -0,0 +1,342 @@ +import { randomUUID } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export const TEST_TEMP_OWNER_FILE = ".opencodex-test-owner.json"; +export const TEST_TEMP_RECOVERY_AGE_MS = 48 * 60 * 60 * 1000; + +const TEST_TEMP_OWNER_VERSION = 1; +const TEST_TEMP_OWNER_KIND = "opencodex-test-root"; +const WRAPPED_TEST_ROOT = /^opencodex-test-[A-Za-z0-9]{6}$/; +const TRANSIENT_REMOVE_CODES = new Set(["EPERM", "EBUSY", "ENOTEMPTY"]); +const DEFAULT_MAX_CANDIDATES = 10_000; +const DEFAULT_MAX_TREE_ENTRIES = 250_000; +const DEFAULT_MAX_DURATION_MS = 30_000; + +/** The first wait after a transient failure. Most release races clear on the first retry. */ +export const REMOVE_RETRY_BASE_DELAY_MS = 50; +/** The ceiling for a single wait, so a long tail never becomes a long stall between attempts. */ +export const REMOVE_RETRY_MAX_DELAY_MS = 250; +/** The total time the schedule may spend waiting on one tree. */ +export const REMOVE_RETRY_BUDGET_MS = 15_000; +/** Reclaiming a stale root is opportunistic: a root that resists briefly is left for a later run. */ +export const RECOVERY_REMOVE_BUDGET_MS = 150; + +interface TestTempOwner { + schemaVersion: 1; + kind: typeof TEST_TEMP_OWNER_KIND; + root: string; + createdAtMs: number; + pid: number; + runId?: string; +} + +export interface TestTempRecoveryResult { + scanned: number; + removed: number; + skipped: number; + errors: number; + truncated: boolean; +} + +type RemoveTreeOptions = Readonly<{ + budgetMs?: number; + delays?: readonly number[]; + remove?: (path: string) => void; + sleep?: (milliseconds: number) => void; +}>; + +type RecoveryOptions = Readonly<{ + tempRoot?: string; + platform?: NodeJS.Platform; + nowMs?: number; + minimumAgeMs?: number; + maxCandidates?: number; + maxTreeEntries?: number; + maxDurationMs?: number; + /** Liveness seam. A recovery test must not depend on which pids the host happens to have. */ + processIsAlive?: (pid: number) => boolean; +}>; + +let automaticRecoveryAttempted = false; + +function errorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error ? String(error.code) : ""; +} + +function samePath(left: string, right: string, platform: NodeJS.Platform): boolean { + const normalizedLeft = resolve(left); + const normalizedRight = resolve(right); + return platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +/** + * The only name shape a reclaimable root can have. + * + * A broader `ocx-*` class was considered and dropped: those directories never carried an + * ownership marker, so under the marker requirement below they could only ever be scanned and + * skipped, and the regex wide enough to catch them was also wide enough to put an unrelated + * tool's directory on the candidate list. + */ +function isTestTempName(name: string): boolean { + return WRAPPED_TEST_ROOT.test(name); +} + +function processIsAlive(pid: number): boolean { + if (pid === process.pid) return true; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return errorCode(error) !== "ESRCH"; + } +} + +function parseOwner(path: string): TestTempOwner | null | undefined { + const markerPath = join(path, TEST_TEMP_OWNER_FILE); + if (!existsSync(markerPath)) return undefined; + try { + const marker = lstatSync(markerPath); + if (!marker.isFile() || marker.isSymbolicLink()) return null; + const parsed = JSON.parse(readFileSync(markerPath, "utf8")) as Partial; + if ( + parsed.schemaVersion !== TEST_TEMP_OWNER_VERSION + || parsed.kind !== TEST_TEMP_OWNER_KIND + || typeof parsed.root !== "string" + || typeof parsed.createdAtMs !== "number" + || !Number.isFinite(parsed.createdAtMs) + || typeof parsed.pid !== "number" + || !Number.isSafeInteger(parsed.pid) + || parsed.pid <= 0 + || (parsed.runId !== undefined && typeof parsed.runId !== "string") + ) return null; + return parsed as TestTempOwner; + } catch { + return null; + } +} + +function inspectTree( + root: string, + budget: { entries: number; deadlineMs: number; now: () => number }, +): { safe: boolean; latestMtimeMs: number } { + const pending = [root]; + let latestMtimeMs = 0; + while (pending.length > 0) { + if (budget.entries <= 0 || budget.now() > budget.deadlineMs) { + return { safe: false, latestMtimeMs }; + } + const current = pending.pop()!; + let entry: ReturnType; + try { + entry = lstatSync(current); + } catch { + return { safe: false, latestMtimeMs }; + } + budget.entries -= 1; + latestMtimeMs = Math.max(latestMtimeMs, entry.mtimeMs); + if (entry.isSymbolicLink()) return { safe: false, latestMtimeMs }; + if (!entry.isDirectory()) continue; + let children: string[]; + try { + children = readdirSync(current); + } catch { + return { safe: false, latestMtimeMs }; + } + for (const child of children) pending.push(join(current, child)); + } + return { safe: true, latestMtimeMs }; +} + +/** + * The waits between removal attempts: exponential from the base delay, capped, bounded by budget. + * + * The predecessor was flat -- 50 attempts at 50ms, so 2.5 seconds total. That budget was tuned on + * a lightly loaded machine and six concurrent Windows shards exceed it, at which point the helper + * rethrows the EPERM it exists to absorb and fails a test that had already finished asserting + * (#4789). Growing the wait instead of the attempt count is what buys a long tail without paying + * for it in the common case: the first retry still lands at 50ms, and a removal that succeeds on + * its first attempt never sleeps at all, so nothing on the passing path gets slower. + */ +export function removeRetrySchedule(budgetMs: number = REMOVE_RETRY_BUDGET_MS): number[] { + const delays: number[] = []; + let spent = 0; + let delay = REMOVE_RETRY_BASE_DELAY_MS; + while (spent + delay <= budgetMs) { + delays.push(delay); + spent += delay; + delay = Math.min(delay * 2, REMOVE_RETRY_MAX_DELAY_MS); + } + return delays; +} + +/** Remove a test-owned tree while tolerating only transient Windows release races. */ +export function removeTestTempTree(path: string, options: RemoveTreeOptions = {}): void { + const delays = options.delays ?? removeRetrySchedule(options.budgetMs); + const remove = options.remove ?? (target => rmSync(target, { recursive: true, force: true })); + const sleep = options.sleep ?? Bun.sleepSync; + + for (let attempt = 0; attempt <= delays.length; attempt += 1) { + try { + remove(path); + return; + } catch (error) { + if (!TRANSIENT_REMOVE_CODES.has(errorCode(error)) || attempt === delays.length) throw error; + sleep(delays[attempt]!); + } + } +} + +/** Stamp a newly created root so future runs can prove its OpenCodex test ownership. */ +export function writeTestTempOwner(root: string, runId?: string): void { + const owner: TestTempOwner = { + schemaVersion: TEST_TEMP_OWNER_VERSION, + kind: TEST_TEMP_OWNER_KIND, + root: realpathSync(root), + createdAtMs: Date.now(), + pid: process.pid, + ...(runId ? { runId } : {}), + }; + const temporary = join(root, `.${TEST_TEMP_OWNER_FILE}.${process.pid}.${randomUUID()}.tmp`); + writeFileSync(temporary, JSON.stringify(owner) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); + renameSync(temporary, join(root, TEST_TEMP_OWNER_FILE)); +} + +/** + * Reclaim stale Windows test roots this tool can PROVE it owns. + * + * Ownership is the marker, not the name. A directory that merely looks like ours is scanned and + * skipped: the accumulation already on a user's machine was written by versions that stamped + * nothing, and deleting it on a name match would be this tool cleaning a TEMP tree it cannot + * show it created. This release therefore changes future runs -- a root stamped by the code + * below is reclaimable, everything older is left alone. + * + * On top of the marker: an exact mkdtemp-shaped name, a 48-hour grace period, direct-parent + * containment, a dead owning pid, and a full no-link walk are all required before removal. + * Invalid ownership metadata fails closed. + */ +export function recoverStaleTestTempArtifacts(options: RecoveryOptions = {}): TestTempRecoveryResult { + const result: TestTempRecoveryResult = { + scanned: 0, + removed: 0, + skipped: 0, + errors: 0, + truncated: false, + }; + const platform = options.platform ?? process.platform; + if (platform !== "win32") return result; + + const nowMs = options.nowMs ?? Date.now(); + const isAlive = options.processIsAlive ?? processIsAlive; + const minimumAgeMs = options.minimumAgeMs ?? TEST_TEMP_RECOVERY_AGE_MS; + const maxCandidates = options.maxCandidates ?? DEFAULT_MAX_CANDIDATES; + const deadlineMs = Date.now() + (options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS); + const budget = { + entries: options.maxTreeEntries ?? DEFAULT_MAX_TREE_ENTRIES, + deadlineMs, + now: Date.now, + }; + + let tempRoot: string; + try { + tempRoot = realpathSync(options.tempRoot ?? tmpdir()); + } catch { + result.errors += 1; + return result; + } + + let names: string[]; + try { + names = readdirSync(tempRoot).sort(); + } catch { + result.errors += 1; + return result; + } + + for (const name of names) { + if (!isTestTempName(name)) continue; + if (result.scanned >= maxCandidates || Date.now() > deadlineMs || budget.entries <= 0) { + result.truncated = true; + break; + } + result.scanned += 1; + const candidate = join(tempRoot, name); + try { + const rootEntry = lstatSync(candidate); + if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) { + result.skipped += 1; + continue; + } + const canonicalCandidate = realpathSync(candidate); + if (!samePath(dirname(canonicalCandidate), tempRoot, platform)) { + result.skipped += 1; + continue; + } + + // An absent marker is as disqualifying as a corrupt one. `undefined` used to mean "no + // evidence either way, proceed on the name", which is exactly the name match this must not + // be. + const owner = parseOwner(candidate); + if (!owner || !samePath(owner.root, canonicalCandidate, platform)) { + result.skipped += 1; + continue; + } + if (isAlive(owner.pid)) { + result.skipped += 1; + continue; + } + const rootActivityMs = Math.max(statSync(candidate).mtimeMs, owner.createdAtMs); + if (nowMs - rootActivityMs < minimumAgeMs) { + result.skipped += 1; + continue; + } + const tree = inspectTree(candidate, budget); + if (!tree.safe) { + result.skipped += 1; + if (Date.now() > deadlineMs || budget.entries <= 0) result.truncated = true; + continue; + } + if (nowMs - Math.max(rootActivityMs, tree.latestMtimeMs) < minimumAgeMs) { + result.skipped += 1; + continue; + } + + removeTestTempTree(candidate, { budgetMs: RECOVERY_REMOVE_BUDGET_MS }); + result.removed += 1; + } catch (error) { + if (errorCode(error) !== "ENOENT") result.errors += 1; + } + } + + return result; +} + +/** Run automatic recovery once per process, before the process creates its own test root. */ +export function recoverStaleTestTempArtifactsOnce( + options: RecoveryOptions = {}, +): TestTempRecoveryResult | null { + if (automaticRecoveryAttempted) return null; + automaticRecoveryAttempted = true; + return recoverStaleTestTempArtifacts(options); +} + +/** Create the contained temp subtree used by every os.tmpdir() call in the child test process. */ +export function createContainedTestTemp(root: string): string { + const contained = join(root, "tmp"); + mkdirSync(contained, { recursive: true }); + return contained; +} diff --git a/scripts/test.ts b/scripts/test.ts index c2c331f5fc5..4406b080867 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, join } from "node:path"; import { @@ -9,6 +9,12 @@ import { TEST_RUN_LOCK_PATH_ENV, TEST_RUN_LOCK_TOKEN_ENV, } from "./test-run-lock"; +import { + createContainedTestTemp, + recoverStaleTestTempArtifactsOnce, + removeTestTempTree, + writeTestTempOwner, +} from "./test-temp"; export interface IsolatedTestEnvironment { root: string; @@ -19,9 +25,20 @@ export interface IsolatedTestEnvironment { export function createIsolatedTestEnvironment( baseEnv: Record = process.env, ): IsolatedTestEnvironment { - const root = mkdtempSync(join(tmpdir(), "opencodex-test-")); + const hostTemp = tmpdir(); + const recovery = recoverStaleTestTempArtifactsOnce({ tempRoot: hostTemp }); + if (recovery && (recovery.removed > 0 || recovery.errors > 0 || recovery.truncated)) { + console.warn( + `[test] stale TEMP recovery removed ${recovery.removed} OpenCodex test root(s)` + + (recovery.errors > 0 ? `; ${recovery.errors} could not be reclaimed` : "") + + (recovery.truncated ? "; the bounded scan will continue on a later run" : "") + + ".", + ); + } + const root = mkdtempSync(join(hostTemp, "opencodex-test-")); const opencodexHome = join(root, ".opencodex"); const codexHome = join(root, ".codex"); + const containedTemp = createContainedTestTemp(root); mkdirSync(opencodexHome, { recursive: true }); mkdirSync(codexHome, { recursive: true }); if (process.platform === "win32") { @@ -35,6 +52,7 @@ export function createIsolatedTestEnvironment( mkdirSync(join(root, "AppData", "Local"), { recursive: true }); mkdirSync(join(root, "AppData", "Roaming"), { recursive: true }); } + writeTestTempOwner(root, baseEnv[TEST_RUN_ID_ENV]); return { root, @@ -60,9 +78,12 @@ export function createIsolatedTestEnvironment( USERPROFILE: root, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, + TEMP: containedTemp, + TMP: containedTemp, + TMPDIR: containedTemp, }, cleanup() { - rmSync(root, { recursive: true, force: true }); + removeTestTempTree(root); }, }; } @@ -527,7 +548,11 @@ export async function runTestLane( } finally { process.off("SIGINT", onInterrupt); process.off("SIGTERM", onTerminate); - isolated.cleanup(); + try { + isolated.cleanup(); + } catch { + console.error("[test] deferred cleanup of one test root after Windows kept a handle open; a later run will retry it."); + } } } diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index da019297d13..4cb391504ac 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -750,7 +750,7 @@ JSON mode: `payload`. ### `ocx system codex-restart` -Restart the Codex app-server. +Restart the Codex desktop app and app-servers. | Method | Route | |---|---| @@ -758,7 +758,7 @@ Restart the Codex app-server. | Flag | Value | Meaning | |---|---|---| -| `--yes` | boolean | Required: restarts the operator's running Codex app-server. | +| `--yes` | boolean | Required: fully quits and relaunches the operator's Codex desktop app and restarts its app-servers. | | `--json` | boolean | Emit the restart result as JSON. | JSON mode: `payload`. diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index 234e06907e3..a769ac37dae 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type SpawnFn } from "../coding-agent/turn"; import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "./profiles"; +import { guardCodeBuddyScaffolding } from "./scaffold-guard"; export type { SpawnFn } from "../coding-agent/turn"; export type CodeBuddyAdapterDeps = CodingAgentDeps; @@ -75,7 +76,7 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu provider, parsed, incoming, - emit, + emit: guardCodeBuddyScaffolding(emit), buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov), buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey), deps, diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts new file mode 100644 index 00000000000..6f8c80aed57 --- /dev/null +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -0,0 +1,248 @@ +import type { AdapterEvent } from "../../types"; + +/** Error code for a CodeBuddy turn whose output contains vendor agent scaffolding. */ +export const CODEBUDDY_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected"; + +// The observed control protocol uses FULLWIDTH VERTICAL LINE (U+FF5C). Detection stays +// deliberately narrower than the marker spelling: a calls control line must be followed by an +// invoke line for a functions.* tool. That distinguishes an agent scaffold from prose quoting or +// discussing one tag. +const DSML_CALLS_LINE = "<||dsml|| calls>"; +const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\"functions."; + +export interface CodeBuddyScaffoldFilterResult { + /** Bytes released from a suffix withheld by an earlier event on this channel. */ + releasedPending: string; + /** Safe bytes belonging to the event currently being processed. */ + text: string; + /** The earlier pending event still owns the extended candidate. */ + pendingContinues: boolean; + fail: boolean; +} + +interface ScanResult { + safe: string; + held: string; + fail: boolean; + fence: "`" | "~" | null; + lineStart: boolean; +} + +function prefixAtEnd(text: string, at: number, expected: string): boolean { + const rest = text.slice(at).toLowerCase(); + return rest.length < expected.length && expected.startsWith(rest); +} + +/** + * Scan complete bytes and retain only a bounded suffix that can still become a control sequence. + * + * Control tags are recognized only at column zero and outside fenced Markdown. Inline code, + * quoted strings, blockquotes, indented source, and prose all add syntax before the tag and are + * therefore forwarded unchanged. A calls line alone is harmless; refusal requires the observed + * two-line calls-plus-functions-invoke grammar. + */ +function scan( + text: string, + initialFence: "`" | "~" | null, + initialLineStart: boolean, +): ScanResult { + let fence = initialFence; + let lineStart = initialLineStart; + let index = 0; + + while (index < text.length) { + if (lineStart) { + const fenceMarkers = fence ? [fence.repeat(3)] : ["```", "~~~"]; + const completeFence = fenceMarkers.find(marker => text.startsWith(marker, index)); + if (completeFence) { + fence = fence ? null : (completeFence[0] as "`" | "~"); + index += completeFence.length; + lineStart = false; + continue; + } + if (fenceMarkers.some(marker => prefixAtEnd(text, index, marker))) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + + if (!fence) { + const lowered = text.slice(index).toLowerCase(); + if (lowered.startsWith(DSML_CALLS_LINE)) { + const afterCalls = index + DSML_CALLS_LINE.length; + let invokeAt = -1; + if (text[afterCalls] === "\n") invokeAt = afterCalls + 1; + else if (text[afterCalls] === "\r" && text[afterCalls + 1] === "\n") invokeAt = afterCalls + 2; + else if (afterCalls === text.length || (text[afterCalls] === "\r" && afterCalls + 1 === text.length)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + + if (invokeAt >= 0) { + const invokeRest = text.slice(invokeAt).toLowerCase(); + if (invokeRest.startsWith(DSML_INVOKE_PREFIX)) { + return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; + } + if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + } + } else if (prefixAtEnd(text, index, DSML_CALLS_LINE)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + } + } + + const char = text[index]!; + index += 1; + lineStart = char === "\n"; + } + + return { safe: text, held: "", fail: false, fence, lineStart }; +} + +/** Streaming DSML control-sequence filter for one text or reasoning channel. */ +export class CodeBuddyScaffoldFilter { + private pending = ""; + private failed = false; + private fence: "`" | "~" | null = null; + private lineStart = true; + + /** True while an earlier event owns an unresolved marker or fence prefix. */ + hasPending(): boolean { + return this.pending.length > 0; + } + + push(chunk: string): CodeBuddyScaffoldFilterResult { + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: false }; + } + if (!chunk) { + return { + releasedPending: "", + text: "", + pendingContinues: this.hasPending(), + fail: false, + }; + } + + const priorPending = this.pending; + const result = scan(priorPending + chunk, this.fence, this.lineStart); + this.pending = result.held; + this.fence = result.fence; + this.lineStart = result.lineStart; + this.failed = result.fail; + + const releasedLength = Math.min(priorPending.length, result.safe.length); + return { + releasedPending: result.safe.slice(0, releasedLength), + text: result.safe.slice(releasedLength), + pendingContinues: priorPending.length > 0 && result.safe.length === 0 && result.held.length > 0, + fail: result.fail, + }; + } + + /** Release a suffix that never completed the two-line control grammar. */ + flush(): CodeBuddyScaffoldFilterResult { + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: false }; + } + const text = this.pending; + this.pending = ""; + return { releasedPending: text, text: "", pendingContinues: false, fail: false }; + } +} + +function codeBuddyScaffoldErrorMessage(): string { + return "CodeBuddy CLI emitted vendor tool-call markup in an assistant output 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 or executing vendor agent scaffolding."; +} + +/** Guard both streamed channels while preserving event order around withheld marker prefixes. */ +export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + const textFilter = new CodeBuddyScaffoldFilter(); + const thinkingFilter = new CodeBuddyScaffoldFilter(); + type PendingChannel = "text" | "thinking"; + type EventSlot = { resolved: boolean; event?: AdapterEvent }; + const eventQueue: EventSlot[] = []; + const pendingSlots = new Map(); + let closed = false; + + const channelEvent = (channel: PendingChannel, text: string): AdapterEvent => channel === "text" + ? { type: "text_delta", text } + : { type: "thinking_delta", thinking: text }; + + const drainResolved = (): void => { + while (eventQueue[0]?.resolved) { + const slot = eventQueue.shift()!; + if (slot.event) emit(slot.event); + } + }; + + const enqueueResolved = (event: AdapterEvent): void => { + eventQueue.push({ resolved: true, event }); + drainResolved(); + }; + + const resolvePendingSlot = (channel: PendingChannel, text: string): void => { + const slot = pendingSlots.get(channel); + if (!slot) return; + slot.resolved = true; + if (text) slot.event = channelEvent(channel, text); + pendingSlots.delete(channel); + drainResolved(); + }; + + const enqueuePendingSlot = (channel: PendingChannel): void => { + const slot: EventSlot = { resolved: false }; + eventQueue.push(slot); + pendingSlots.set(channel, slot); + }; + + const flushAllPending = (): void => { + for (const channel of ["text", "thinking"] as const) { + if (!pendingSlots.has(channel)) continue; + const filter = channel === "text" ? textFilter : thinkingFilter; + resolvePendingSlot(channel, filter.flush().releasedPending); + } + drainResolved(); + }; + + const refuse = (): void => { + if (closed) return; + flushAllPending(); + closed = true; + emit({ + type: "error", + message: codeBuddyScaffoldErrorMessage(), + status: 502, + errorType: "upstream_error", + code: CODEBUDDY_SCAFFOLD_ERROR_CODE, + retryable: false, + }); + }; + + return (event: AdapterEvent): void => { + if (closed) return; + if (event.type === "text_delta" || event.type === "thinking_delta") { + const channel: PendingChannel = event.type === "text_delta" ? "text" : "thinking"; + const filter = channel === "text" ? textFilter : thinkingFilter; + const hadPending = filter.hasPending(); + const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking); + if (hadPending && !cleaned.pendingContinues) resolvePendingSlot(channel, cleaned.releasedPending); + if (cleaned.text) { + enqueueResolved(event.type === "text_delta" + ? { ...event, text: cleaned.text } + : { ...event, thinking: cleaned.text }); + } + if (filter.hasPending() && !cleaned.pendingContinues) enqueuePendingSlot(channel); + if (cleaned.fail) refuse(); + return; + } + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + flushAllPending(); + closed = true; + emit(event); + return; + } + enqueueResolved(event); + }; +} diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 4b7c707d7ef..3c466829e28 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -469,7 +469,7 @@ async function fetchCommandCode(request: AdapterRequest, ctx: AdapterFetchContex const timer = setTimeout(() => timeout.abort(new DOMException("Timeout elapsed", "TimeoutError")), ctx?.timeoutMs ?? 200_000); const callerSignal = ctx?.abortSignal ?? new AbortController().signal; try { - return await executor(request.url, { + return await (ctx?.executor ?? executor)(request.url, { method: request.method, headers: request.headers, body: request.body, diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index ffaf3df193b..336a2767939 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -217,7 +217,13 @@ export type RoutingCommentaryDecision = | { kind: "flush" } | { kind: "hallucination" }; -const ROUTING_NATIVE_TOOL_NAME = /\b(shell|read|grep|list|bash)\b/giu; +// The Korean alternative is deliberately asymmetric: it has a left boundary and no right one. +// Korean attaches particles directly to the noun, so the real sentences this detector exists to +// catch read "네이티브 셸이 차단되어..." and "네이티브 셸과 Read가...". A mirrored +// (?![\p{L}\p{M}\p{N}_]) lookahead would see the 이/과 particle as a letter and stop matching +// every one of them, which is why the negative cases below only probe the left side. Adding the +// right boundary looks like an obvious fix and disables the check; do not. +const ROUTING_NATIVE_TOOL_NAME = /\b(shell|read|grep|list|bash)\b|(? match[1]?.toLowerCase()), + [...this.buffered.matchAll(ROUTING_NATIVE_TOOL_NAME)].map(match => match[1]?.toLowerCase() ?? "shell"), ); if (nativeTools.size === 0) return false; return ROUTING_REDIRECT_CLAIM.test(this.buffered) || nativeTools.size >= 2; diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9617c1ac100..8829dc07846 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -796,14 +796,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // body, URL or credential. const requestedTextFormat = parsed.options.textFormat; if (requestedTextFormat) { - if (provider.googleMode === "cloud-code-assist") { - // Not implemented or verified by opencodex for the Cloud Code Assist envelope, - // including Claude models served through it. This is not a claim that the - // upstream cannot do it — silence would return unconstrained prose as success, - // which is the failure this fix exists to remove. + if (provider.googleMode === "cloud-code-assist" && !parsed.modelId.startsWith("gemini-")) { + // Not implemented by opencodex for non-Gemini models (including Claude) + // served through the Cloud Code Assist envelope. This is not a claim that + // the upstream cannot do it — silence would return unconstrained prose as success, + // which is the failure this refusal exists to prevent. throw new Error( - "google cloud-code-assist structured output is not implemented by opencodex — " - + "remove response_format or route this model through AI Studio or Vertex", + "google cloud-code-assist structured output is not implemented by opencodex for non-Gemini models — " + + "remove response_format or route this model through a direct provider", ); } if (isImageCapableModel(parsed.modelId)) { diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 3662d8caaab..730ad2a2c4e 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -3,7 +3,7 @@ import { kiroTruncationReason } from "./kiro-truncation"; export type ParsedKiroEvent = | { type: "content"; data?: string; modelId?: string } - | { type: "reasoning"; data?: string; redactedContent?: string } + | { type: "reasoning"; data?: string; signature?: string; redactedContent?: string } | { type: "context_usage"; contextUsagePercentage: number } | { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean } | { type: "truncation"; data: string } @@ -138,18 +138,26 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi : {}), }; case "reasoningContentEvent": - // `text` is plaintext reasoning; `redactedContent` is the encrypted blob the GPT-5.6 family - // (sol/terra/luna) actually returns — they never send `text`. Keyed off the wire field, not - // the model id. Both may be absent on a bare event. - return { - type: "reasoning", - ...(optionalString(eventType, parsed, "text") !== undefined - ? { data: optionalString(eventType, parsed, "text") } - : {}), - ...(optionalString(eventType, parsed, "redactedContent") !== undefined - ? { redactedContent: optionalString(eventType, parsed, "redactedContent") } - : {}), - }; + // `text` is plaintext reasoning; the GPT-5.6 family (sol/terra/luna) instead returns an + // encrypted blob, and the field it arrives on has to be replayed unchanged (see + // kiro/reasoning.ts): `signature` carries the `.KTR~~…` value verbatim and is what every + // capture of those models sent, while `redactedContent` — the base64 shape a capture has + // never shown — stays accepted for any model that sends it. Keyed off the wire field, not the + // model id. Any of the three may be absent on a bare event. + { + const text = optionalString(eventType, parsed, "text"); + const signature = optionalString(eventType, parsed, "signature"); + const redacted = optionalString(eventType, parsed, "redactedContent"); + return { + type: "reasoning", + ...(text !== undefined ? { data: text } : {}), + ...(signature !== undefined + ? { signature } + : redacted !== undefined + ? { redactedContent: redacted } + : {}), + }; + } case "toolUseEvent": return { type: "tool", diff --git a/src/adapters/kiro/payload.ts b/src/adapters/kiro/payload.ts index 4da79a9bcc0..1fbd3bcbfd0 100644 --- a/src/adapters/kiro/payload.ts +++ b/src/adapters/kiro/payload.ts @@ -38,7 +38,12 @@ import { validateKiroConversationState, type KiroTurn, } from "./conversation"; -import { injectKiroThinkingTags, kiroNativeEffortField, KIRO_NATIVE_EFFORTS } from "./reasoning"; +import { + injectKiroThinkingTags, + kiroNativeEffortField, + kiroReasoningContent, + KIRO_NATIVE_EFFORTS, +} from "./reasoning"; import { kiroPayloadMessages, userContentText } from "./usage"; import { kiroToolWireNames, @@ -388,7 +393,11 @@ export function buildKiroPayload( assistantResponseMessage: { content: turn.content, ...(turn.toolUses.length > 0 ? { toolUses: turn.toolUses } : {}), - ...(turn.redactedReasoning ? { reasoningContent: { redactedContent: turn.redactedReasoning } } : {}), + // Replayed on the field it was received on: the GPT-5.6 signature is not base64 and is + // rejected when sent as `redactedContent`. + ...(turn.redactedReasoning + ? { reasoningContent: kiroReasoningContent(turn.redactedReasoning) } + : {}), }, } : { @@ -447,7 +456,12 @@ export function buildKiroPayload( if (!KIRO_NATIVE_EFFORTS.includes(effort)) { throw new Error(`Kiro ${normalizeKiroModelId(parsed.modelId)} does not support reasoning effort ${JSON.stringify(effort)}`); } - payload.additionalModelRequestFields = { [effortField]: { effort } }; + // Model eligibility still owns unsupported-effort validation above; wire eligibility + // is narrower for luna/terra, whose unverified rungs retain the thinking-tag path. + const verifiedEffortField = kiroNativeEffortField(parsed.modelId, effort); + if (verifiedEffortField) { + payload.additionalModelRequestFields = { [verifiedEffortField]: { effort } }; + } } if (profileArn) payload.profileArn = profileArn; return { payload, nameMap, conversationId, completionMode }; diff --git a/src/adapters/kiro/reasoning.ts b/src/adapters/kiro/reasoning.ts index c218bf12339..d12c95654ca 100644 --- a/src/adapters/kiro/reasoning.ts +++ b/src/adapters/kiro/reasoning.ts @@ -4,21 +4,46 @@ import type { OcxParsedRequest } from "../../types"; export type KiroReasoningMode = "native" | "emulated"; // Kiro takes a verified native effort field for these models, and each model family names it -// differently: the Sol-only `reasoning.effort` versus the Claude-specific `output_config.effort`. -// Models absent from this table fall back to emulated thinking instructions. +// differently: the GPT-5.6 family's `reasoning.effort` versus the Claude-specific +// `output_config.effort`. Models absent from this table fall back to emulated thinking +// instructions. +// +// The GPT-5.6 entries are measured against the live runtime rather than inferred from the vendor +// schema: the field is accepted (HTTP 200) and the encrypted reasoning blob that comes back grows +// with the effort. On one fixed hard prompt — a primality search plus a 20-bit recurrence count — +// luna's blob measured 5,130 chars at `low`, 16,686 at `medium`, 30,670 at `high` and 48,594 at +// `max`, against 13,118 with no effort signal at all; terra's measured 34,590 and 38,106 at native +// `max` against 11,758 and 17,598 bare, two repetitions each. The channel this replaces — the +// emulated `` tag block, which was all those models used to receive — measured +// 21,202 (`low`) and 28,302 (`max`) for luna, i.e. between that model's native `medium` and +// `high`, never reaching native `max`. `gpt-5.6-sol`'s native `max` cross-checked at 30,498 on the +// same prompt. Terra's absence from this table was therefore an omission rather than a capability +// difference: what the earlier Sol-only scope recorded was not reproducible here. export const KIRO_NATIVE_EFFORT_FIELDS: Record = { "gpt-5.6-sol": "reasoning", + "gpt-5.6-terra": "reasoning", + "gpt-5.6-luna": "reasoning", "claude-opus-5": "output_config", }; export const KIRO_NATIVE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -export function kiroNativeEffortField(modelId: string): "reasoning" | "output_config" | undefined { - return KIRO_NATIVE_EFFORT_FIELDS[normalizeKiroModelId(modelId)]; +// The newly enabled models have evidence for these rungs only. Keep the previous +// emulation for xhigh, and never widen their native wire when the shared ladder grows. +const KIRO_LUNA_TERRA_NATIVE_EFFORTS = new Set(["low", "medium", "high", "max"]); + +export function kiroNativeEffortField( + modelId: string, + effort?: string, +): "reasoning" | "output_config" | undefined { + const model = normalizeKiroModelId(modelId); + if ((model === "gpt-5.6-luna" || model === "gpt-5.6-terra") + && effort !== undefined && !KIRO_LUNA_TERRA_NATIVE_EFFORTS.has(effort)) return undefined; + return KIRO_NATIVE_EFFORT_FIELDS[model]; } -export function kiroReasoningMode(modelId: string): KiroReasoningMode { - return kiroNativeEffortField(modelId) ? "native" : "emulated"; +export function kiroReasoningMode(modelId: string, effort?: string): KiroReasoningMode { + return kiroNativeEffortField(modelId, effort) ? "native" : "emulated"; } export function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined { @@ -38,7 +63,7 @@ export function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined } export function injectKiroThinkingTags(content: string, parsed: OcxParsedRequest): string { - if (kiroReasoningMode(parsed.modelId) !== "emulated") return content; + if (kiroReasoningMode(parsed.modelId, parsed.options.reasoning) !== "emulated") return content; const budget = kiroThinkingBudget(parsed); if (!budget) return content; const instruction = [ @@ -54,3 +79,41 @@ export function injectKiroThinkingTags(content: string, parsed: OcxParsedRequest content, ].join("\n"); } + +/** + * The blob from a Kiro `reasoningContentEvent` has two possible homes on a replayed assistant + * turn, and the wire validates the SHAPE of each rather than its content: `signature` takes the + * emitted string verbatim, while `redactedContent` is a base64 member. The `.KTR~~…` value every + * GPT-5.6 capture returns is NOT valid base64, which is exactly why replaying it as + * `redactedContent` — what this proxy did before the field was measured — came back as + * REQUEST_BODY_INVALID ("Improperly formed request"). + * + * The blob travels as ONE opaque string: adapter event, `ocxr1:` reasoning envelope, then + * `OcxAssistantMessage.kiroRedactedReasoning`. The field it arrived on therefore rides that same + * string, instead of a second parallel value that could drift from it. Provider data cannot forge + * the tag: the other channel is base64, whose alphabet has no colon. + */ +export const KIRO_REASONING_SIGNATURE_TAG = "signature:"; + +export function tagKiroReasoningBlob(field: "signature" | "redactedContent", data: string): string { + return field === "signature" ? KIRO_REASONING_SIGNATURE_TAG + data : data; +} + +/** The wire field a stored blob arrived on, and its untagged value. */ +export function splitKiroReasoningBlob(value: string): { field: "signature" | "redactedContent"; data: string } { + return value.startsWith(KIRO_REASONING_SIGNATURE_TAG) + ? { field: "signature", data: value.slice(KIRO_REASONING_SIGNATURE_TAG.length) } + : { field: "redactedContent", data: value }; +} + +/** + * The `reasoningContent` object on an `assistantResponseMessage`. Exactly one member is set: the + * wire validates the shape, so the two cannot be substituted for each other. + */ +export type KiroReasoningContent = { signature: string } | { redactedContent: string }; + +/** `reasoningContent` for a replayed `assistantResponseMessage`, carrying the blob verbatim. */ +export function kiroReasoningContent(value: string): KiroReasoningContent { + const { field, data } = splitKiroReasoningBlob(value); + return field === "signature" ? { signature: data } : { redactedContent: data }; +} diff --git a/src/adapters/kiro/stream.ts b/src/adapters/kiro/stream.ts index 1740cf8d64e..d10ab1105c0 100644 --- a/src/adapters/kiro/stream.ts +++ b/src/adapters/kiro/stream.ts @@ -19,6 +19,7 @@ import { noteKiroTransientThrottle } from "../kiro-retry"; import { KiroThinkingParser } from "../kiro-thinking"; import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "../kiro-truncation"; import { isValidKiroConversationId } from "../kiro-wire"; +import { tagKiroReasoningBlob } from "./reasoning"; import { estimateKiroTokens, kiroUpstreamContextWindow } from "./usage"; // Stream parsing (shared by parseStream + parseResponse) @@ -633,8 +634,13 @@ async function* parseKiroAttemptEvents( if (ev.data) { yield* emitRetained(stage({ type: "reasoning_raw_delta", text: ev.data })); } - if (ev.redactedContent) { - yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: ev.redactedContent })); + // The blob is replayed on the field it arrived on, so remember that field here — this is + // the only place that still knows it. See kiro/reasoning.ts for why the distinction is + // load-bearing rather than cosmetic. + if (ev.signature) { + yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: tagKiroReasoningBlob("signature", ev.signature) })); + } else if (ev.redactedContent) { + yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: tagKiroReasoningBlob("redactedContent", ev.redactedContent) })); } break; case "context_usage": diff --git a/src/adapters/kiro/wire.ts b/src/adapters/kiro/wire.ts index ec8c32272da..7bf91d9db55 100644 --- a/src/adapters/kiro/wire.ts +++ b/src/adapters/kiro/wire.ts @@ -1,5 +1,6 @@ import type { OcxProviderConfig } from "../../types"; import type { KiroImage } from "../kiro-images"; +import type { KiroReasoningContent } from "./reasoning"; export const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; export const SDK_VERSION = "1.0.27"; @@ -51,7 +52,7 @@ export interface KiroHistoryEntry { assistantResponseMessage?: { content: string; toolUses?: KiroToolUse[]; - reasoningContent?: { redactedContent: string }; + reasoningContent?: KiroReasoningContent; }; } diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8503210e465..d74640a3e23 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -6,7 +6,6 @@ import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; -import { frameAgentRouterMessages } from "./agentrouter"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../providers/service-tier"; @@ -39,6 +38,7 @@ import { upstreamErrorEvent, } from "./openai-chat/errors"; import { messagesToChatFormat } from "./openai-chat/messages"; +import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; import { isNativeOpenAIChatTarget, openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; @@ -88,7 +88,7 @@ function canSerializeOpenAIChatServiceTier( export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter { let lastRequestedModelId: string | undefined; - return { + return withOpenAIChatToolNames(toolNames => ({ name: "openai-chat", formatErrorBody: formatOpenAIChatErrorBody, @@ -96,10 +96,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) { lastRequestedModelId = parsed.modelId; const { url, headers, hasCredential } = openAIChatTransport(provider); - const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider)); + const messages = toolNames.messages(parsed, provider.baseUrl, messagesToChatFormat(parsed, provider)); const finish = (): AdapterRequest => { - const tools = toolsToChatFormatForProvider(parsed, provider); - const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider); + const tools = toolsToChatFormatForProvider(parsed, provider, toolNames.registry()); + const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider, toolNames.registry()); const body: Record = { model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId, @@ -365,7 +365,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd return "terminate"; } if (!call.id) call.id = `call_${++toolCallSeq}`; - yield { type: "tool_call_start", id: call.id, name: call.name }; + yield { type: "tool_call_start", id: call.id, name: toolNames.restore(call.name) }; if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args }; yield { type: "tool_call_end" }; } @@ -801,7 +801,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd logInvalidToolCalls("response", rawToolCalls); return [invalidToolCallsEvent(rawToolCalls, "response", usage)]; } - events.push({ type: "tool_call_start", id, name }); + events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }); events.push({ type: "tool_call_delta", arguments: args }); events.push({ type: "tool_call_end" }); } @@ -818,5 +818,5 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd budget.releaseRetained(responseBytes, { kind: "retained_collectors" }); } }, - }; + })); } diff --git a/src/adapters/openai-chat/tool-name-registry.ts b/src/adapters/openai-chat/tool-name-registry.ts new file mode 100644 index 00000000000..c9c8e1fa5ff --- /dev/null +++ b/src/adapters/openai-chat/tool-name-registry.ts @@ -0,0 +1,166 @@ +import { createHash } from "node:crypto"; +import { namespacedToolName, type OcxParsedRequest, type OcxTool } from "../../types"; +import { frameAgentRouterMessages } from "../agentrouter"; + +const MAX_CHAT_TOOL_NAME_LENGTH = 64; +const ALIAS_HINT_CHARS = 16; +const RESERVED_ALIAS_PATTERN = /^ocx_[a-zA-Z0-9_-]{16}_[a-zA-Z0-9_-]{43}$/; +type ToolIdentity = Readonly>; + +export interface OpenAIChatToolNameRegistry { + alias(tool: ToolIdentity): string; + aliasWireName(wireName: string): string; + restore(wireName: string): string; +} + +interface OpenAIChatToolNameScope { + messages(parsed: OcxParsedRequest, baseUrl: string, messages: readonly unknown[]): unknown; + registry(): OpenAIChatToolNameRegistry; + restore(wireName: string): string; +} + +function identityKey(tool: ToolIdentity): string { + return JSON.stringify([tool.namespace ?? null, tool.name]); +} + +function boundedAlias(tool: ToolIdentity, wireName: string): string { + const hint = wireName + .replace(/[^a-zA-Z0-9_-]/g, "_") + .slice(-ALIAS_HINT_CHARS) + .padStart(ALIAS_HINT_CHARS, "_"); + const key = identityKey(tool); + const digest = createHash("sha256") + .update(key) + .digest("base64url"); + return `ocx_${hint}_${digest}`; +} + +/** Catalog declarations plus structured calls retained in replay history. */ +export function openAIChatToolNameIdentities(parsed: OcxParsedRequest): ToolIdentity[] { + const identities: ToolIdentity[] = [...(parsed.context.tools ?? [])]; + for (const message of parsed.context.messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (part.type !== "toolCall") continue; + identities.push({ + name: part.name, + ...(part.namespace === undefined ? {} : { namespace: part.namespace }), + }); + } + } + return identities; +} + +/** + * One collision domain for a translated Chat Completions request. + * + * Namespaced names whose flattened spelling exceeds Chat Completions' 64-character function-name + * bound are rewritten. Ordinary names and bare names pass through byte-for-byte unless they occupy + * the reserved alias spelling; those are re-aliased so no declaration can shadow another identity's + * deterministic alias. Distinct identities sharing one flattened spelling each keep an identity + * alias, while replay rewriting leaves that ambiguous spelling untouched. Echoed aliases restore to + * the original flattened name consumed by the Responses bridge's existing namespace map. + */ +export function createOpenAIChatToolNameRegistry( + tools: readonly ToolIdentity[] | undefined, +): OpenAIChatToolNameRegistry { + const identities = new Map(); + for (const tool of tools ?? []) identities.set(identityKey(tool), tool); + const sortedIdentities = [...identities.entries()] + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + + const aliasesByIdentity = new Map(); + const aliasesByWireName = new Map(); + const originalsByAlias = new Map(); + const wireOwners = new Map(); + for (const [key, tool] of sortedIdentities) { + const wireName = namespacedToolName(tool.namespace, tool.name); + const owner = wireOwners.get(wireName); + if (owner === undefined) wireOwners.set(wireName, key); + else if (owner !== key) wireOwners.set(wireName, null); + } + + const aliasOwners = new Map(); + const wireClaims = new Map(); + for (const [key, tool] of sortedIdentities) { + const wireName = namespacedToolName(tool.namespace, tool.name); + const candidate = (tool.namespace !== undefined && wireName.length > MAX_CHAT_TOOL_NAME_LENGTH) + || RESERVED_ALIAS_PATTERN.test(wireName) + || wireOwners.get(wireName) === null + ? boundedAlias(tool, wireName) + : wireName; + // A full SHA-256 collision is not safely attributable. Keep the later identity's native + // spelling instead of failing the request or stealing the first identity's restore entry. + const alias = aliasOwners.has(candidate) ? wireName : candidate; + aliasesByIdentity.set(key, alias); + if (!aliasOwners.has(alias)) aliasOwners.set(alias, key); + if (alias !== wireName) originalsByAlias.set(alias, wireName); + + const claim = wireClaims.get(wireName); + if (claim === undefined) wireClaims.set(wireName, { key, alias }); + else if (claim !== null && claim.key !== key) wireClaims.set(wireName, null); + } + for (const [wireName, claim] of wireClaims) { + if (claim !== null) aliasesByWireName.set(wireName, claim.alias); + } + + return { + alias(tool: ToolIdentity): string { + const key = identityKey(tool); + const known = aliasesByIdentity.get(key); + if (known !== undefined) return known; + return namespacedToolName(tool.namespace, tool.name); + }, + aliasWireName(wireName: string): string { + return aliasesByWireName.get(wireName) ?? wireName; + }, + restore(wireName: string): string { + return originalsByAlias.get(wireName) ?? wireName; + }, + }; +} + +export function restoreOpenAIChatToolName( + registry: OpenAIChatToolNameRegistry, + wireName: string, +): string { + return registry.restore(wireName); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Rewrite replayed assistant tool calls after the ordinary message converter has flattened them. */ +export function aliasOpenAIChatMessageToolNames( + messages: readonly unknown[], + registry: OpenAIChatToolNameRegistry, +): unknown[] { + return messages.map(message => { + if (!isRecord(message) || !Array.isArray(message.tool_calls)) return message; + let changed = false; + const toolCalls = message.tool_calls.map(toolCall => { + if (!isRecord(toolCall) || !isRecord(toolCall.function) + || typeof toolCall.function.name !== "string") return toolCall; + const name = registry.aliasWireName(toolCall.function.name); + if (name === toolCall.function.name) return toolCall; + changed = true; + return { ...toolCall, function: { ...toolCall.function, name } }; + }); + return changed ? { ...message, tool_calls: toolCalls } : message; + }); +} + +export function withOpenAIChatToolNames( + build: (scope: OpenAIChatToolNameScope) => T, +): T { + let registry = createOpenAIChatToolNameRegistry(undefined); + return build({ + messages(parsed, baseUrl, messages): unknown { + registry = createOpenAIChatToolNameRegistry(openAIChatToolNameIdentities(parsed)); + return frameAgentRouterMessages(baseUrl, aliasOpenAIChatMessageToolNames(messages, registry)); + }, + registry: () => registry, + restore: wireName => restoreOpenAIChatToolName(registry, wireName), + }); +} diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index c056a6a043a..23f77121bd5 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -1,7 +1,8 @@ import { isNativeOpenAIChatTarget } from "./wire"; +import { createOpenAIChatToolNameRegistry, type OpenAIChatToolNameRegistry } from "./tool-name-registry"; import { isXaiSchemaTarget, lookupLocalJsonPointer, normalizeXaiToolParameters } from "../xai-tool-schema"; import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "../responses-tool-schema"; -import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; +import { isAllowedToolChoice, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; import type { OcxParsedRequest, OcxProviderConfig } from "../../types"; const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]); @@ -409,7 +410,11 @@ function normalizeMoonshotToolParameters(parameters: unknown): Record 0 ? formatted : undefined; } -export function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined { - const base = toolsToChatFormat(parsed, provider); +export function toolsToChatFormatForProvider( + parsed: OcxParsedRequest, + provider: OcxProviderConfig, + registry: OpenAIChatToolNameRegistry = createOpenAIChatToolNameRegistry(parsed.context.tools), +): unknown[] | undefined { + const base = toolsToChatFormat(parsed, provider, registry); const azureChat = isAzureOpenAiChatTarget(provider); const zenChat = shouldSanitizeZenToolParameters(provider); if (!base || (!zenChat && !azureChat)) return base; @@ -463,15 +472,24 @@ export function toolChoiceToChatFormat( tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"], provider: OcxProviderConfig, + registry: OpenAIChatToolNameRegistry = createOpenAIChatToolNameRegistry(tools), ): unknown { if (!tc) return undefined; if (isAllowedToolChoice(tc)) { if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) { - return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } }; + return { + type: "function", + function: { name: registry.aliasWireName(resolveToolChoiceWireName(tools, tc.allowedTools[0])) }, + }; } return tc.mode === "required" ? "required" : "auto"; } if (tc === "auto" || tc === "none" || tc === "required") return tc; - if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } }; + if ("name" in tc) { + return { + type: "function", + function: { name: registry.aliasWireName(resolveToolChoiceWireName(tools, tc.name)) }, + }; + } return undefined; } diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 4cde7b8d3cd..740130ab043 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -41,6 +41,19 @@ import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuation import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; +/** + * Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need + * plaintext reasoning and cannot consume opaque reasoning state. The two existing flags are + * current evidence for that one provider contract, not equivalent capabilities: preservation + * keeps plaintext reasoning on the wire, while adjacency marks its strict tool-history shape. + * The moment a second provider needs this behavior, replace this derivation with an explicit + * registry capability rather than extending the inference. + */ +export function requiresPlaintextReasoningReplay(provider: OcxProviderConfig): boolean { + return provider.preserveResponsesReasoningContent === true + && provider.requiresAdjacentResponsesToolResults === true; +} + // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode. // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call. export const FORWARD_HEADERS = [ @@ -64,6 +77,16 @@ export const FORWARD_HEADERS = [ CODEX_RESPONSES_LITE_HEADER, ]; +/** Preserve the caller fingerprint unless the provider explicitly owns that header. */ +function applyCallerUserAgentFallback( + headers: Record, + incoming: IncomingMeta, +): void { + if (Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) return; + const userAgent = incoming.headers.get("user-agent"); + if (userAgent) headers["User-Agent"] = userAgent; +} + /** Replace every `input_image` part under a routed-compaction body with a short marker. */ function stripInputImagesDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripInputImagesDeep); @@ -221,6 +244,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); } + // Some Responses-compatible gateways select their Codex compatibility path from the real + // client fingerprint. This is a single non-credential fallback, not broader caller-header + // forwarding. Static provider headers remain authoritative in either auth mode. + applyCallerUserAgentFallback(headers, incoming); const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; @@ -357,6 +384,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; + // Providers with the strict plaintext tool-continuation contract cannot consume any + // encrypted reasoning blob, including one whose provenance is unknown. Combo routing + // separately refuses a proven cross-route replay when no plaintext exists; this final + // serializer guard ensures the foreign opaque state is never forwarded regardless. const sanitizedBody = normalizeToolSchemas( stripItemIdsWhenUnstored( stripInvalidItemIds( @@ -370,7 +401,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), - stripEncryptedContent: threadServingIdentityChanged, + stripEncryptedContent: threadServingIdentityChanged || requiresPlaintextReasoningReplay(provider), }, ), provider, diff --git a/src/bridge/errors.ts b/src/bridge/errors.ts index 175e3ec451d..aa270195792 100644 --- a/src/bridge/errors.ts +++ b/src/bridge/errors.ts @@ -1,3 +1,10 @@ +import { + isNonReplayableUpstreamCode, + isReplayRefusalCode, + markReplayRefusalResponse, + markResponseNonReplayable, + REPLAY_REFUSED_STATUS, +} from "../lib/upstream-retry"; import { adapterFailureFromMessage, classifyError, @@ -18,17 +25,34 @@ export function formatErrorResponse( error.code = CYBER_POLICY_ERROR_CODE; error.type = cyberPolicyErrorType(type); } - const finalStatus = error.code === CYBER_POLICY_ERROR_CODE ? 400 : status; + // Only the allowlisted transport verdicts survive this formatter. Do not forward + // arbitrary provider codes, and preserve the existing cyber-policy precedence. + const replayBlocked = error.code !== CYBER_POLICY_ERROR_CODE + && isNonReplayableUpstreamCode(options?.code); + if (replayBlocked) error.code = options!.code!; + // The replay refusal owns its status as well as its code. A combo or adapter formatter + // reaches here holding the upstream-shaped status it was about to report, and inheriting + // that would hand the client a 5xx it is configured to retry four times. + const finalStatus = error.code === CYBER_POLICY_ERROR_CODE + ? 400 + : isReplayRefusalCode(error.code) ? REPLAY_REFUSED_STATUS : status; const headers = new Headers({ "Content-Type": "application/json" }); const retryAfter = options?.retryAfter?.trim(); if (error.code !== CYBER_POLICY_ERROR_CODE + && !replayBlocked && retryAfter && retryAfter.length > 0 && retryAfter.length <= 128) { headers.set("Retry-After", retryAfter); } - return new Response(JSON.stringify({ error }), { + const response = new Response(JSON.stringify({ error }), { status: finalStatus, headers, }); + if (replayBlocked) markResponseNonReplayable(response); + // Re-wrapping is where the refusal loses its provenance: combo failure consumption parses + // the JSON and builds a new Response, and the code alone does not tell a later quota + // recorder that no upstream produced this status. Carry the narrower marker across too. + if (replayBlocked && isReplayRefusalCode(error.code)) markReplayRefusalResponse(response); + return response; } diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index 6b2deec9858..365f664264b 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -68,6 +68,8 @@ function buildResponseJSONWithBudget( toolNsMap?: Map; /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ declaredToolNames?: ReadonlySet; + /** See `bridgeToResponsesSSE`: enforcement is separate from normalization (#4735). */ + enforceDeclaredToolNames?: boolean; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ toolParameterSchemas?: ReadonlyMap>; freeformToolNames?: Set; @@ -432,7 +434,11 @@ function buildResponseJSONWithBudget( } flushToolCall(); const effectiveName = normalizeDeclaredToolName(e.name, options?.declaredToolNames); - if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) { + if ( + options?.declaredToolNames + && options.enforceDeclaredToolNames !== false + && !options.declaredToolNames.has(effectiveName) + ) { errorEvent = { type: "error", message: `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`, diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 43d4f0b9f71..db0adbdb7c7 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -88,6 +88,20 @@ export function bridgeToResponsesSSE( onUsage?: (usage: OcxUsage | undefined) => void; /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ declaredToolNames?: ReadonlySet; + /** + * Whether `declaredToolNames` is an authorization boundary this proxy enforces, or only the + * catalog used to normalize provider-invented names back to declared ones. + * + * Defaults to enforcing. The chat and Anthropic inbound wires set it false: those specs make + * the server relay a tool call and leave execution or refusal to the client's own runner, and + * harnesses on them legitimately defer part of their catalog (#4735). + * + * It is a separate flag rather than simply withholding `declaredToolNames`, because the set + * also drives `normalizeDeclaredToolName` and `declaresCodeModeExec`. Passing `undefined` + * turns those off too, so a provider that invents `default.lookup` for a declared `lookup` + * would reach the client under the invented name instead of the normalized one. + */ + enforceDeclaredToolNames?: boolean; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ toolParameterSchemas?: ReadonlyMap>; /** @@ -1008,7 +1022,11 @@ export function bridgeToResponsesSSE( : undefined; const mapped = toolNsMap?.get(effectiveName); const realName = mapped?.name ?? effectiveName; - if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) { + if ( + options?.declaredToolNames + && options.enforceDeclaredToolNames !== false + && !options.declaredToolNames.has(effectiveName) + ) { const failure = responseError( 502, "upstream_error", diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index 20652cf34bf..e35b74cb464 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -22,8 +22,34 @@ export interface RenderedDesktopModel extends DesktopProfileModel { supports1m: boolean; } -const DATE_ALIAS = /^claude-opus-4-8-(2026\d{4})$/; -const DAY_COUNT_2026 = 365; +// Managed-namespace date aliases run 2026-2035. The original 2026-only +// (365 slots) design failed with "all 365 encoded date slots are occupied" +// once a catalog exceeded 365 routes (stale assignments are retained by +// design, so the set only grows). Years before 2026 stay rejected: dated +// ids like `claude-opus-4-8-20250201` are real model snapshot ids, not +// managed aliases, and the inbound decoder relies on that distinction. +// Every emitted suffix stays 8 digits so modelMap date-stripping keeps +// working. +const DATE_ALIAS = /^claude-opus-4-8-(202[6-9]\d{4}|203[0-5]\d{4})$/; +const LEGACY_YEAR = 2026; +const LEGACY_DAY_COUNT = 365; +const ALIAS_FIRST_YEAR = 2026; +const ALIAS_LAST_YEAR = 2035; + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function daysInAliasYear(year: number): number { + return isLeapYear(year) ? 366 : 365; +} + +export const TOTAL_ALIAS_SLOTS = (() => { + let total = 0; + for (let year = ALIAS_FIRST_YEAR; year <= ALIAS_LAST_YEAR; year += 1) total += daysInAliasYear(year); + return total; +})(); +export const ALIAS_YEAR_RANGE = { first: ALIAS_FIRST_YEAR, last: ALIAS_LAST_YEAR } as const; export class DesktopProfileError extends Error { constructor(message: string, readonly path = "profile") { @@ -119,7 +145,7 @@ export function parseDesktopProfile(value: unknown): DesktopProfile { if (isRealAnthropicRoute(route)) { if (raw.alias !== routeModelId(route)) throw new DesktopProfileError("real Anthropic routes must keep their exact model id", `profile.assignments.${route}.alias`); } else if (!validDateAlias(raw.alias)) { - throw new DesktopProfileError("must be a valid claude-opus-4-8-2026MMDD alias", `profile.assignments.${route}.alias`); + throw new DesktopProfileError("must be a valid claude-opus-4-8-YYYYMMDD alias", `profile.assignments.${route}.alias`); } if (aliases.has(raw.alias)) throw new DesktopProfileError(`duplicate alias "${raw.alias}"`, `profile.assignments.${route}.alias`); aliases.add(raw.alias); @@ -144,26 +170,57 @@ export function parseDesktopProfile(value: unknown): DesktopProfile { return { version: 1, assignments, defaults, ...appliedMarkers(value) }; } -function dayOfYearAlias(dayIndex: number): string { - const date = new Date(Date.UTC(2026, 0, dayIndex + 1)); +function formatSlotDate(year: number, dayOfYear: number): string { + const date = new Date(Date.UTC(year, 0, dayOfYear)); const y = date.getUTCFullYear(); const m = String(date.getUTCMonth() + 1).padStart(2, "0"); const d = String(date.getUTCDate()).padStart(2, "0"); return `claude-opus-4-8-${y}${m}${d}`; } +// Legacy 2026 ring, byte-identical to the original allocator: the same route +// must keep resolving to the same 2026 alias it always had, and a probe over +// a nearly-full 2026 set must land on the same free date as before. +function legacyDayAlias(dayIndex: number): string { + return formatSlotDate(LEGACY_YEAR, dayIndex + 1); +} + +// Overflow ring for catalogs past 365 routes (2027-2035). Probed only after +// every legacy slot is taken, so existing profiles never shift into it. +const OVERFLOW_FIRST_YEAR = LEGACY_YEAR + 1; +const OVERFLOW_SLOT_COUNT = TOTAL_ALIAS_SLOTS - LEGACY_DAY_COUNT; + +function overflowSlotAlias(slotIndex: number): string { + let remaining = ((slotIndex % OVERFLOW_SLOT_COUNT) + OVERFLOW_SLOT_COUNT) % OVERFLOW_SLOT_COUNT; + for (let year = OVERFLOW_FIRST_YEAR; year <= ALIAS_LAST_YEAR; year += 1) { + const days = daysInAliasYear(year); + if (remaining < days) return formatSlotDate(year, remaining + 1); + remaining -= days; + } + throw new DesktopProfileError("slot index out of range", "profile.assignments"); +} + function routeStartDay(route: string): number { - return createHash("sha256").update(route).digest().readUInt32BE(0) % DAY_COUNT_2026; + return createHash("sha256").update(route).digest().readUInt32BE(0) % LEGACY_DAY_COUNT; +} + +function routeOverflowStart(route: string): number { + return createHash("sha256").update(route).digest().readUInt32BE(4) % OVERFLOW_SLOT_COUNT; } function allocateAlias(route: string, used: Set): string { if (isRealAnthropicRoute(route)) return routeModelId(route); const start = routeStartDay(route); - for (let offset = 0; offset < DAY_COUNT_2026; offset += 1) { - const alias = dayOfYearAlias((start + offset) % DAY_COUNT_2026); + for (let offset = 0; offset < LEGACY_DAY_COUNT; offset += 1) { + const alias = legacyDayAlias((start + offset) % LEGACY_DAY_COUNT); + if (!used.has(alias)) return alias; + } + const overflowStart = routeOverflowStart(route); + for (let offset = 0; offset < OVERFLOW_SLOT_COUNT; offset += 1) { + const alias = overflowSlotAlias((overflowStart + offset) % OVERFLOW_SLOT_COUNT); if (!used.has(alias)) return alias; } - throw new DesktopProfileError("all 365 encoded date slots are occupied", `profile.assignments.${route}.alias`); + throw new DesktopProfileError(`all ${TOTAL_ALIAS_SLOTS} encoded date slots are occupied`, `profile.assignments.${route}.alias`); } export function reconcileDesktopProfile( diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index d4e7758ee0c..f281b632b75 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -212,6 +212,8 @@ interface OpenBlock { argsBuf?: string; argsBufBytes?: number; webSearchArgsEmitted?: boolean; + /** True once ordinary function-call arguments were emitted to Anthropic SSE. */ + toolArgsEmitted?: boolean; callId?: string; /** Last fixed-size reasoning identity (item + summary/content index) seen by this block. */ reasoningPartKey?: string; @@ -488,6 +490,7 @@ export function responsesSseToAnthropicSse( argsBuf: "", argsBufBytes: 0, webSearchArgsEmitted: false, + toolArgsEmitted: false, }; break; } @@ -518,6 +521,14 @@ export function responsesSseToAnthropicSse( type: "content_block_delta", index: open.index, delta: { type: "input_json_delta", partial_json: data.delta }, }); + open.toolArgsEmitted = true; + break; + } + case "response.function_call_arguments.done": { + if (!open || open.kind !== "tool_use" || open.bufferWebSearchArgs || open.toolArgsEmitted) break; + if (typeof data.arguments !== "string" || data.arguments.length === 0) break; + emit("content_block_delta", { type: "content_block_delta", index: open.index, delta: { type: "input_json_delta", partial_json: data.arguments } }); + open.toolArgsEmitted = true; break; } case "response.output_item.done": { @@ -565,6 +576,13 @@ export function responsesSseToAnthropicSse( delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) }, }); open.webSearchArgsEmitted = true; + } else if (!open.bufferWebSearchArgs && !open.toolArgsEmitted + && typeof item.arguments === "string" && item.arguments.length > 0) { + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "input_json_delta", partial_json: item.arguments }, + }); + open.toolArgsEmitted = true; } closeOpenBlock(); } diff --git a/src/cli/account-main.ts b/src/cli/account-main.ts index 9169ab824a8..437981d62f6 100644 --- a/src/cli/account-main.ts +++ b/src/cli/account-main.ts @@ -245,7 +245,7 @@ export async function cmdNativeMainAccount(args: string[], deps: AccountDeps): P } if (noWait) { printStatus({ flowId: startFlowId, ...pending }); - console.log("follow up: ocx account main reauth status --flow " + startFlowId); + if (!wantsJson) console.log("follow up: ocx account main reauth status --flow " + startFlowId); return 0; } // Blocking wait bounded by the service flow expiry (15-minute grant + margin). diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 36aa8124cc3..309badbfd24 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -710,10 +710,10 @@ export const CAPABILITIES: readonly Capability[] = [ }, { command: ["system", "codex-restart"], - summary: "Restart the Codex app-server.", + summary: "Restart the Codex desktop app and app-servers.", routes: [{ method: "POST", path: "/api/system/codex-restart" }], flags: [ - { name: "--yes", value: "boolean", summary: "Required: restarts the operator's running Codex app-server." }, + { name: "--yes", value: "boolean", summary: "Required: fully quits and relaunches the operator's Codex desktop app and restarts its app-servers." }, { name: "--json", value: "boolean", summary: "Emit the restart result as JSON." }, ], mutates: true, diff --git a/src/cli/combo.ts b/src/cli/combo.ts index 3e0aa0d0bf2..380bcd36ff4 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -15,7 +15,8 @@ const USAGE = `Usage: ocx combo show [--json] ocx combo set --targets [--strategy ] [--sticky <1-100>] - [--effort ] [--alias ] + [--effort ] [--effort-mode ] + (force overrides valid client effort and can increase cost/latency) [--alias ] [--native-alias] [--display-name ] [--rename-from ] [--json] ocx combo remove --yes [--json]`; @@ -80,6 +81,10 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { if (strategy !== "round-robin") throw new CliUsageError("--sticky applies only to round-robin", USAGE); } const effort = takeOption(args, "--effort"); + const effortMode = takeOption(args, "--effort-mode"); + if (effortMode !== undefined && effortMode !== "fallback" && effortMode !== "force") { + throw new CliUsageError("--effort-mode must be fallback or force", USAGE); + } const alias = takeOption(args, "--alias"); const nativeAlias = takeFlag(args, "--native-alias"); const displayName = takeOption(args, "--display-name"); @@ -91,12 +96,16 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { targets: parseTargets(targetsRaw), }; if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort; + if (effortMode !== undefined) combo.defaultEffortMode = effortMode; if (alias !== undefined) combo.alias = alias === "-" ? "" : alias; if (nativeAlias) combo.nativeAlias = true; if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName; const current = await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", {}, deps); const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id)); if (existing?.imageInput === "disabled") combo.imageInput = "disabled"; + if (effortMode === undefined && existing?.defaultEffortMode === "force") { + combo.defaultEffortMode = effort === "-" ? "fallback" : "force"; + } const result = await runtimeRequest("/api/combos", { method: "PUT", body: JSON.stringify({ id, combo, ...(renameFrom ? { renameFrom } : {}) }), diff --git a/src/cli/index.ts b/src/cli/index.ts index 9d37cb5c210..9df96b3fad5 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,7 +15,7 @@ try { } import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject"; import { stripGrokConfig } from "../grok/inject"; -import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs"; +import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs"; import { describeHistoryJobFailure, resolveCodexHistoryJobTarget, @@ -46,6 +46,7 @@ import { isPendingTeardownAbandoned, listPendingTeardowns, pendingTeardownPathFor, + pendingTeardownsAreExactly, quarantinePendingTeardown, } from "../config/pending-teardown"; import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; @@ -785,9 +786,16 @@ async function handleRestartStartWhenStopped(): Promise { * * The distinction exists because `ocx update` must proceed for the first and abort for the * second, and it can only see an exit code (#3008). + * + * `historyDeferred` is the third kind (#4718). The Codex history preflight refuses BEFORE + * the config half runs, so nothing was restored at all: config, catalog, history and + * provenance are untouched and the client is still routed at the proxy that just stopped. + * Like `historyOnly` the proxy is genuinely down, so an update may replace package files. + * Unlike `historyOnly` the obligation was not performed, so the receipt must survive. */ -async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; other: boolean }> { +async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; historyDeferred: boolean; other: boolean }> { let historyOnly = false; + let historyDeferred = false; let other = false; try { const result = await restoreNativeCodexAsync(); @@ -798,7 +806,16 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole // not — a client reads those, so their failure is a real teardown failure. const artifacts = result.artifacts; const configOrCatalogFailed = artifacts.config.state === "failed" || artifacts.catalog.state === "failed"; - if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true; + // A preflight refusal reports every artifact as `skipped` because none of them were + // attempted. Reading the states alone cannot tell that apart from an ownership + // refusal, so the structured reason carries it and the states are still required to + // agree — a refusal that somehow reports a failed artifact is not this case. + const preflightRefused = result.historyPreflightRefusal !== undefined + && artifacts.config.state === "skipped" + && artifacts.catalog.state === "skipped" + && artifacts.history.state === "skipped"; + if (preflightRefused) historyDeferred = true; + else if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true; else other = true; console.error(`⚠️ ${result.message}`); } @@ -816,7 +833,7 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole other = true; console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`); } - return { historyOnly, other }; + return { historyOnly, historyDeferred, other }; } async function handleStop() { @@ -860,6 +877,11 @@ async function handleStop() { }; let stopFailed = false; let historyOnlyFailure = false; + /** + * Obligations this run deliberately kept because the Codex history preflight refused + * before restoring anything (#4718). Non-null selects the deferred exit code. + */ + let historyDeferredNonces: string[] | null = null; // Only Task Scheduler respawns after a successful stop (#764), so only it earns the // restart-window wait; launchd, systemd and WinSW are down when they say so. let schedulerCanRespawn = false; @@ -1138,6 +1160,7 @@ async function handleStop() { } const restore = await restoreSharedClientStateAfterStop(); if (restore.other) stopFailed = true; + else if (restore.historyDeferred) historyDeferredNonces = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces; else if (restore.historyOnly) historyOnlyFailure = true; // The obligation is discharged whether or not history metadata finalized: config and // catalog are what a client reads, and `restore.other` already fails the stop. @@ -1145,7 +1168,16 @@ async function handleStop() { // Each nonce names its own file, so a clear can only ever remove the obligation it // names — never one a concurrent stop wrote. Both this run's claim and every inherited // receipt it proved discharged are released together. - if (!restore.other) { + // + // A history-preflight refusal is the exception: it restored nothing, so there is + // nothing to discharge. Clearing here would drop a real obligation on the floor and + // leave the client config pointing at a proxy that is gone, with nothing on disk + // saying so — which is the whole failure the receipt exists to prevent (#4718). + if (restore.historyDeferred) { + console.error(" The shared teardown was refused before it changed anything, so it is still owed."); + console.error(" Its receipt is preserved; run 'ocx stop' again once Codex is closed to retry the restore."); + } + if (!restore.other && !restore.historyDeferred) { const discharged = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces; for (const nonce of discharged) { // A receipt that survives its discharge re-triggers recovery forever, so a failed @@ -1185,6 +1217,17 @@ async function handleStop() { // still wins: it is the stronger signal. if (stopFailed) process.exitCode = 1; else if (historyOnlyFailure) process.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE; + // The deferred code says "the only obligations left are the ones I just decided to + // keep". It is read across a process boundary by an updater that will replace package + // files on the strength of it, so this run has to be able to prove the claim: if any + // other obligation is sitting in the home — quarantined, or a concurrent stop's — the + // claim is false and the ordinary failure code is the honest answer. That is also the + // behaviour before #4718, so the fallback loses nothing that used to work. + else if (historyDeferredNonces) { + process.exitCode = pendingTeardownsAreExactly(historyDeferredNonces) + ? STOP_HISTORY_DEFERRED_EXIT_CODE + : 1; + } return !stopFailed; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index bfbe845c5ba..d716ccffef0 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -407,7 +407,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.", "When Claude routing is explicitly disabled, it launches natively after removing proven OpenCodex-owned proxy state.", - "Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).", + "Routed models appear in the native /model picker with stable claude-opus-4-8-YYYYMMDD slot aliases,", + "where the year runs 2026-2035 and 2026 slots are allocated first (Claude Code >= 2.1.129).", "Older versions: pick models via ANTHROPIC_MODEL or /model directly (any string passes through).", "User-exported ANTHROPIC_* variables take precedence for routed launches; native fallback removes only proven OpenCodex-owned proxy values.", "", diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index 03eb9008873..a3b46b49d69 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -130,14 +130,14 @@ export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps = const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE); printData(await runtimeRequest("/api/system/codex-app-server", {}, deps), wantsJson); } else if (sub === "codex-restart") { - // --yes required: this restarts the user's running Codex app-server, so it is exactly the - // class of action that must not happen because an agent guessed a subcommand. + // --yes required: this fully quits and relaunches the user's Codex desktop app as well as + // restarting app-servers; an agent guessing a subcommand must not interrupt that session. const args = [...rest]; const wantsJson = takeFlag(args, "--json"); const yes = takeFlag(args, "--yes"); - if (!yes) throw new CliUsageError("system codex-restart requires --yes", USAGE); + if (!yes) throw new CliUsageError("system codex-restart requires --yes: this fully quits and relaunches the Codex desktop app and restarts its app-servers", USAGE); rejectArgs(args, USAGE); - printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex app-server restart requested."]); + printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex desktop app and app-server restart requested."]); } else if (sub === "update") await update(rest, deps); else throw new CliUsageError(`unknown system command ${sub}`, USAGE); }); diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 21f08581797..0f6c756a258 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -812,6 +812,7 @@ export interface OpenclawModelEntry { id: string; name: string; contextWindow?: number; + input?: string[]; } export interface OpenclawProviderBlock { @@ -839,15 +840,15 @@ export interface KimiProviderBlock { /** * `max_context_size` is mandatory and must be positive, so a model with no * authoritative context window is omitted from the document entirely rather - * than guessed at. `capabilities` is never emitted: our catalog does not - * assert them, and Kimi's own inference works off OpenAI-style name prefixes - * that a routed selector will not match. + * than guessed at. Catalog image input becomes `image_in`; other capabilities + * are not inferred from routed model names. */ export interface KimiModelBlock { provider: string; model: string; max_context_size: number; display_name?: string; + capabilities?: ["image_in"]; } export interface KimiGeneratedConfig { @@ -974,10 +975,12 @@ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig { function buildOpenclawClientConfig(ctx: ExportContext): OpenclawGeneratedConfig { const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).map(model => { const context = authoritativeContextWindow(model.contextWindow); + const input = [...new Set(model.inputModalities?.filter(value => ["text", "image", "video", "audio"].includes(value)))]; return { id: model.namespaced, name: exportModelLabel(model), ...(context !== undefined ? { contextWindow: context } : {}), + ...(input.length > 0 ? { input } : {}), }; }); const headers = proxyAdmissionHeaders(ctx.config, OPENCLAW_API_KEY_ENV_REF); @@ -1015,6 +1018,7 @@ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig { model: model.namespaced, max_context_size: context, ...(model.displayName ? { display_name: model.displayName } : {}), + ...(model.inputModalities?.includes("image") ? { capabilities: ["image_in"] as ["image_in"] } : {}), }; } return { diff --git a/src/codex/account-label.ts b/src/codex/account-label.ts index b0a4ab76029..046462670bb 100644 --- a/src/codex/account-label.ts +++ b/src/codex/account-label.ts @@ -1,17 +1,19 @@ import { createHash, randomBytes } from "node:crypto"; import type { CodexAccount, OcxConfig } from "../types"; import type { CodexAuthContext } from "./auth-context"; +import type { ProviderApiKeySelection } from "../types/provider"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/; /** - * Account log labels come in two families (#2699): + * Account log labels come in three families: * * - `p` (plus the literal `main`) — a Codex pool account. * - `o` — a non-Codex OAuth provider account (xai, cursor, and siblings). + * - `k` — a request-owned API-key selection, scoped to provider and reference. * - * Both are sha256-derived digests, never an email and never a raw provider account id. That is + * Labels never contain an email, raw key/reference, or raw provider account id. That is * a privacy requirement, not a formatting preference: these labels are written to the usage log * and served over the management API. * @@ -20,7 +22,16 @@ export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/; * accepted cost of keeping the existing `p` format byte-compatible. */ export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^o[a-f0-9]{6}$/; -export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6})$/; +export const KEY_ACCOUNT_LOG_LABEL_RE = /^k[a-f0-9]{32}$/; +export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6}|k[a-f0-9]{32})$/; + +/** Digest the request-owned configured selection, never serialize its key/reference. */ +export function apiKeyAccountLogLabel(provider: string, selection: ProviderApiKeySelection | undefined): `k${string}` | undefined { + if (!selection || typeof selection.reference !== "string" || !selection.reference.length) return undefined; + return `k${createHash("sha256").update(JSON.stringify([ + "ocx-key-account-v1", provider, selection.entryId ?? null, selection.reference, + ])).digest("hex").slice(0, 32)}`; +} export function oauthAccountLogLabel(accountId: string, provider = ""): string { return `o${createHash("sha256").update(`${provider}\0${accountId}`).digest("hex").slice(0, 6)}`; diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 1e8885ae11a..f695884cf1e 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { ConfigMutationLockError, @@ -268,6 +268,23 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord return loadCodexAccountRecordStore()[id] ?? null; } +/** + * One store load, every record, for a caller that resolves MANY ids in a single synchronous pass. + * + * `readCodexAccountRecord` reloads, reparses and renormalizes the whole file per id. That is the + * right shape for one lookup and the wrong shape for a loop: the entitlement denial reader holds + * up to 64 accounts with four client versions each, so scoring one warm flagship request could + * perform up to 256 full-store reads on the request path. + * + * These are the same normalized records `readCodexAccountRecord` hands out, tombstones included, + * so the caller keeps its own `deletedAt` and `generation` checks instead of trusting a filtered + * view. That is the difference from `loadCodexAccountStore`, which drops both and cannot answer a + * question about credential generation. + */ +export function loadCodexAccountRecordSnapshot(): Readonly> { + return loadCodexAccountRecordStore(); +} + const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; function validQuotaHistoryIdentity(value: unknown): value is string { @@ -655,10 +672,43 @@ function isRefreshLockStale(path: string): boolean { const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown }; return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS; } catch { - return true; + // The owner creates the file and writes its metadata in two steps, so a live lock is + // briefly unreadable. Age the file itself instead of calling that window stale, which + // let a waiter delete a lock whose owner was still inside its critical section. + try { + return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS; + } catch { + return false; + } } } +function releaseCodexRefreshFileLock(path: string, fd: number): void { + let owned: { dev: bigint; ino: bigint } | null = null; + try { + const info = fstatSync(fd, { bigint: true }); + if (info.dev >= 0n && info.ino > 0n) owned = { dev: info.dev, ino: info.ino }; + } catch { /* Unknown descriptor identity never authorizes unlink. */ } + try { + withConfigMutationLockSync(() => { + let current: { dev: bigint; ino: bigint } | null = null; + try { + const info = statSync(path, { bigint: true }); + if (info.dev >= 0n && info.ino > 0n) current = { dev: info.dev, ino: info.ino }; + } catch { /* Keep the lock and the callback outcome when the path probe fails. */ } + if (owned && current && current.dev === owned.dev && current.ino === owned.ino) { + try { unlinkSync(path); } catch (err) { + if (errCode(err) !== "ENOENT") throw err; + } + } + }); + } catch (err) { + // Keep the descriptor alive through comparison/unlink so its inode cannot be recycled. + // Unavailable coordination leaves the path without masking the completed refresh. + if (!(err instanceof ConfigMutationLockError)) throw err; + } finally { closeSync(fd); } +} + export async function withCodexRefreshFileLock(lockKey: string, signal: AbortSignal, fn: () => Promise): Promise { hardenConfigDir(); const dir = getConfigDir(); @@ -670,33 +720,45 @@ export async function withCodexRefreshFileLock(lockKey: string, signal: Abort while (fd == null) { if (signal.aborted) throw signal.reason; try { - fd = openSync(path, "wx", 0o600); - writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n"); - break; - } catch (err) { - if (errCode(err) !== "EEXIST") throw err; - if (isRefreshLockStale(path)) { + // Serialize only metadata operations, never the async refresh callback. Cooperating + // contenders cannot reclaim a successor between stale observation and path mutation. + withConfigMutationLockSync(() => { try { - unlinkSync(path); - } catch (unlinkErr) { - if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr; + fd = openSync(path, "wx", 0o600); + writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n"); + } catch (err) { + if (fd != null) { + const failedFd = fd; + fd = null; + try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve write failure. */ } + throw err; + } + if (errCode(err) !== "EEXIST") throw err; + if (isRefreshLockStale(path)) { + try { unlinkSync(path); } catch (unlinkErr) { + if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr; + } + } } - continue; + }); + } catch (err) { + // A failed SQLite commit can follow successful file creation; it still owns an fd. + if (fd != null) { + const failedFd = fd; + fd = null; + try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve admission failure. */ } } - if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError(); - await sleep(REFRESH_LOCK_POLL_MS, signal); + if (!(err instanceof ConfigMutationLockError)) throw err; } + if (fd != null) break; + if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError(); + await sleep(REFRESH_LOCK_POLL_MS, signal); } try { return await fn(); } finally { - if (fd != null) closeSync(fd); - try { - unlinkSync(path); - } catch (err) { - if (errCode(err) !== "ENOENT") throw err; - } + releaseCodexRefreshFileLock(path, fd); } } @@ -1137,9 +1199,20 @@ async function resolveCodexToken( let errDesc: string; let errCodeExact: string | undefined; try { - const parsed = JSON.parse(errText) as { error?: string; error_description?: string }; - errCodeExact = typeof parsed.error === "string" ? parsed.error.trim() : undefined; - errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${res.status}`; + const parsed = JSON.parse(errText) as { + error?: string | { code?: string; message?: string }; + error_description?: string; + }; + if (typeof parsed.error === "string") { + errCodeExact = parsed.error.trim(); + errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": "); + } else if (parsed.error && typeof parsed.error === "object") { + errCodeExact = typeof parsed.error.code === "string" ? parsed.error.code.trim() : undefined; + errDesc = [parsed.error.code, parsed.error.message, parsed.error_description].filter(Boolean).join(": "); + } else { + errDesc = parsed.error_description || `HTTP ${res.status}`; + } + if (!errDesc) errDesc = `HTTP ${res.status}`; } catch { errDesc = `HTTP ${res.status}`; } // `invalid_grant` is the standard OAuth code for a refresh token that is no longer // usable, and upstream sends it bare with no description. Without it here the dead @@ -1149,9 +1222,23 @@ async function resolveCodexToken( // Matched on the exact `error` CODE, not anywhere in the combined text: a transient // `server_error` whose description happens to mention invalid_grant would otherwise // retire a healthy account, which is the failure this whole change exists to remove. - const reason = errCodeExact === "invalid_grant" - || errDesc.includes("invalidated") || errDesc.includes("revoked") ? "revoked" as const - : errDesc.includes("expired") ? "expired" as const + // + // That rule binds the DESCRIPTION words too. "invalidated", "revoked" and "expired" read + // as terminal prose, but upstream puts arbitrary text there: a `server_error` whose + // description says "token was revoked" or "session expired" is still a 5xx blip, and + // retiring the account on it is exactly the false quarantine #2887 exists to prevent. + // So a body that carries a structured code is classified by that code ALONE. The + // substring fallback survives only where there is no structured code to read at all -- + // a description-only body, or one this parser could not decode -- because there the + // prose is the only signal upstream gave us. + const structuredCode = errCodeExact ? errCodeExact : undefined; + const proseIsOnlySignal = structuredCode === undefined; + const reason = structuredCode === "invalid_grant" + || structuredCode === "refresh_token_invalidated" + || (proseIsOnlySignal + && (errDesc.includes("invalidated") || errDesc.includes("revoked"))) ? "revoked" as const + : structuredCode === "refresh_token_expired" + || (proseIsOnlySignal && errDesc.includes("expired")) ? "expired" as const : "unknown" as const; throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`); } diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 5427fed8413..973ae5cd45c 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -18,6 +18,27 @@ export interface CodexAccountUsabilityOptions { isMainAccountTokenLive?: typeof isMainAccountTokenLive; /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */ modelEligibleAccountIds?: ReadonlySet; + /** + * Accounts whose own confirmed roster definitively omits the requested model (#4768). + * + * Deliberately NOT read by this module. `modelEligibleAccountIds` is an eligibility boundary and + * produces `model_not_entitled`; this is an ORDERING preference applied once, in + * `getEligiblePoolAccounts`, and dropped whenever honouring it would leave no candidate. Reading + * it here would turn a preference into a refusal and re-create the fail-closed behaviour the + * flagships were deliberately taken out of. + */ + deniedModelAccountIds?: ReadonlySet; + /** + * This request's conversation carries live uploaded-file references (#4778). + * + * Also not read by this module, and for the same reason: it is a retention preference, never an + * eligibility boundary. Uploaded files are scoped to the account that issued them, so moving + * such a conversation orphans the reference and every later turn is refused with + * `409 account_change_file_scope` -- the reference stays in history, so the conversation is + * effectively dead. Retention makes that refusal rarer; it can never replace it, because an + * account can always become unable to serve. + */ + retainAccountForUploadedFiles?: boolean; } /** diff --git a/src/codex/auth-api/login-flow.ts b/src/codex/auth-api/login-flow.ts index ad384e95396..fa6d1c6fbf7 100644 --- a/src/codex/auth-api/login-flow.ts +++ b/src/codex/auth-api/login-flow.ts @@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { emailMaskingEnabled, projectEmail } from "../../lib/privacy"; -import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup"; +import { CodexWarmupError, codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup"; import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../../types"; import type { CatalogDisposition } from "../convergence-types"; import { isValidCodexAccountId } from "../account-id"; @@ -51,6 +51,17 @@ export async function verifyCodexAccountWarmup( return { ok: true, validatedAt: Date.now() }; } catch (err) { const reason = codexWarmupFailureReason(err); + if (err instanceof CodexWarmupError && err.code === "http_status" && err.status === 429) { + return { + ok: false, + response: jsonResponse({ + error: "Codex account warmup was rate limited. Retry later or after the account's usage limit resets.", + code: "codex_warmup_rate_limited", + reason, + accountId, + }, 429), + }; + } return { ok: false, response: jsonResponse({ @@ -322,10 +333,11 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig, ? { ok: true as const, validatedAt: undefined } : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId); if (!warmup.ok) { - const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string }; + const body = await warmup.response.json().catch(() => ({})) as { error?: string; code?: string; reason?: string }; setCodexLoginState(flowId, { status: "error", error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed", + code: body.code, doneAt: Date.now(), }); completed = true; diff --git a/src/codex/auth-api/reset-credit-service.ts b/src/codex/auth-api/reset-credit-service.ts index 232904c5204..03997ea2a92 100644 --- a/src/codex/auth-api/reset-credit-service.ts +++ b/src/codex/auth-api/reset-credit-service.ts @@ -153,7 +153,9 @@ export function createResetCreditWhamClient(config: OcxConfig, accountId: string signal: AbortSignal.timeout(10_000), }); if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } - return safeResetCreditConsumeDto(await resp.json()); + const parsed = await readResetCreditJson(resp, AbortSignal.timeout(10_000)); + if (!parsed.ok) throw new Error("invalid upstream reset-credit consume response"); + return safeResetCreditConsumeDto(parsed.value); }), }; } @@ -383,7 +385,14 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string, if (identity) markManualResetCreditOperationAmbiguous(identity); return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); } - const result = safeResetCreditConsumeDto(await resp.json()); + const consumed = await readResetCreditJson(resp, AbortSignal.timeout(10_000)); + if (!consumed.ok) { + // The spend may already have landed upstream and its outcome code is unreadable, + // so this id must never come back as a new operation. + if (identity) markManualResetCreditOperationAmbiguous(identity); + return jsonResponse({ error: "Invalid upstream reset-credit consume response" }, 502); + } + const result = safeResetCreditConsumeDto(consumed.value); if (identity) { // Narrow explicitly rather than casting: `safeResetCreditConsumeDto` // normalizes anything unrecognized to "unknown", and settling that diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 54431e0b7c8..356ee9012f8 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -39,7 +39,12 @@ import { pickAlternateCodexAccount, resolveCodexAccountForThreadDetailed, type CodexAffinityDecision, + type CodexThreadResolution, + type TransientProbeGrant, } from "./routing"; +// The half-open TRANSIENT-HOLD lease (#4701). Not the quota-cooldown probe lease imported from +// ./routing above -- different module, different domain, and a request never holds both. +import { releaseTransientProbe } from "../routing/probe-lease"; import { codexConversationIdentity, recordCodexThreadLineage, @@ -47,6 +52,7 @@ import { type CodexThreadLineage, } from "./lineage"; import { + cachedDeniedCodexAccountIdsForModel, entitledCodexAccountIdsForModel, isDirectCallerEntitledToCodexModel, resolveCodexModelEntitlements, @@ -202,6 +208,12 @@ export type CodexAuthContext = affinityDecision?: CodexAffinityDecision; /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */ probeQuotaScope?: CodexQuotaScope; + /** + * Set when this request is the ONE dispatch admitted to test an account held under a + * transient 5xx hold (#4701). Echo it into the upstream outcome so the trial is settled + * by the request that ran it, and release it on any path that never reaches upstream. + */ + transientProbe?: TransientProbeGrant; } | { // Main Codex account participating in rotation: token injected from ~/.codex/auth.json @@ -222,6 +234,8 @@ export type CodexAuthContext = probeLeaseId?: string; quotaScope?: CodexQuotaScope; probeQuotaScope?: CodexQuotaScope; + /** See `pool.transientProbe`. */ + transientProbe?: TransientProbeGrant; }; /** Probe lease carried by this context, when it holds one. */ @@ -234,11 +248,24 @@ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQu return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined; } +/** The transient-hold recovery probe carried by this context, when it holds one (#4701). */ +export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): TransientProbeGrant | undefined { + return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.transientProbe : undefined; +} + /** * Hand back a probe lease for a request that will not reach upstream. Safe to * call with a context that holds no lease. + * + * BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold + * probe, and every one of the ~30 call sites that already hands back the first is a path where + * the second would leak too. Releasing them together is what makes those sites correct for the + * new lease without re-deriving the discard set by hand -- the failure mode being avoided is a + * held account nobody may probe because the request that held the trial went away quietly. */ export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void { + const transientProbe = codexTransientProbeGrant(ctx); + if (transientProbe) releaseTransientProbe(transientProbe.lease); const leaseId = codexProbeLeaseId(ctx); if (!ctx || ctx.kind === "main" || !leaseId) return; if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId); @@ -424,6 +451,44 @@ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableE } } +/** + * Every account bound to this conversation is held after upstream failures, the recovery + * budget for this window is spent, and there is no detour left -- so this request is refused + * BEFORE any upstream I/O (#4701). + * + * This is not a quota cooldown, and the message below says so. It subclasses + * {@link CodexAccountCooldownError} for one reason: the deadline-carrying refusal has exactly + * one representation in this codebase, and roughly a dozen transports already map it to a 429 + * with `Retry-After` and treat it as an expected terminal answer rather than a credential + * fault. Introducing a parallel type would mean either re-deriving that handling in every one + * of them or silently falling through to a 500 in the ones that were missed. + * + * What must NOT be inherited is the quota wording -- "cooling down", `ocx account + * clear-cooldown` -- because none of it describes a 5xx hold and following it would do + * nothing. {@link cooldownErrorMessage} therefore returns this class's own message verbatim, + * the same escape hatch {@link CodexMainAccountHardLockError} and + * {@link CodexReserveUnavailableError} already use. + * + * `cooldownUntil` carries the limiter's own change point, which is strictly in the future: + * either the moment the held account may next be probed or the moment the recovery window + * moves, whichever is later. A refusal that answered `now` would busy-loop the caller into + * the same load it just declined. + */ +export class CodexRecoveryWithheldError extends CodexAccountCooldownError { + /** The sibling still remembered for this thread, when one exists but is itself unusable. */ + readonly detourAccountId?: string; + + constructor(accountId: string, retryAt: number, detourAccountId?: string) { + super(accountId, retryAt); + this.name = "CodexRecoveryWithheldError"; + this.detourAccountId = detourAccountId; + this.message = `Codex account (${cooldownAccountLabel(accountId)}) is held after repeated upstream` + + ` failures and the pool's recovery budget for this window is spent, so nothing was sent` + + ` upstream. Retry after ${new Date(retryAt).toISOString()}.` + + " This clears on its own as the account recovers; no cooldown to lift and no account to switch."; + } +} + export type CodexAuthPolicyConfig = Readonly>; @@ -634,7 +699,12 @@ export function cooldownAccountLabel(accountId: string): string { * injected `openai_base_url` in config.toml. */ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string { - if (err instanceof CodexMainAccountHardLockError || err instanceof CodexReserveUnavailableError) return err.message; + if (err instanceof CodexMainAccountHardLockError + || err instanceof CodexReserveUnavailableError + // A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate + // one, and the quota recovery advice below would send the operator after a cooldown that + // does not exist (#4701). + || err instanceof CodexRecoveryWithheldError) return err.message; const until = new Date(err.cooldownUntil).toISOString(); const scopeLabels: Record = { shared: "shared native quota", reserve: "Reserve quota", @@ -717,6 +787,11 @@ export interface ResolveCodexAuthContextOptions { requestScopedMainCredential?: boolean; /** Test seam for a Direct request's own forwarded ChatGPT credential. */ isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise; + /** + * This request's conversation carries live uploaded-file references (#4778). Retains the bound + * account across a VOLUNTARY quota move; involuntary release is untouched. + */ + retainAccountForUploadedFiles?: boolean; } export interface CodexAccountSelectionAdmission { @@ -870,6 +945,15 @@ export async function resolveCodexAuthContext( // Why this request is on this account, carried to the request log so a move reads as an event // instead of something inferred from account labels across lines (#4546). let affinityDecision: CodexAffinityDecision | undefined; + // The half-open trial this request was granted, if it is the one allowed to test a held + // account. Declared out here because the release paths below and the returned context are on + // opposite sides of several throws (#4701). + let transientProbe: TransientProbeGrant | undefined; + const releaseTransientProbeGrant = (): void => { + if (!transientProbe) return; + releaseTransientProbe(transientProbe.lease); + transientProbe = undefined; + }; // Retained startup recovery makes the physical main identity ineligible. Routing // can still preserve service by selecting a healthy configured pool account. A // request-owned bearer likewise cannot inspect or reconcile file-main state. @@ -900,6 +984,23 @@ export async function resolveCodexAuthContext( const modelEligibleAccountIds = entitledAccountIds ? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate))) : undefined; + // #4768: the flagships stay visible and never fail closed, so this is evidence routing may + // ORDER by, not evidence it may refuse on. Read synchronously from rosters discovery has + // already gathered -- no upstream fetch joins the request path for the most commonly + // requested models in the product -- and passed to selection as a preference that is dropped + // whenever honouring it would leave no candidate. + // + // Under the SAME exclusion the entitlement snapshot above uses. The reader validates each + // cached roster against the account's current credential, and for native main that is a + // synchronous read of the physical stored token -- exactly what this request is forbidden to + // touch while a profile switch drains it or while it is served by a request-owned credential. + // Excluding main here costs nothing: the preference is an ordering hint, so main becomes + // unknown rather than denied, and unknown leaves selection exactly as it was. + const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel( + options.modelId, + undefined, + { excludeAccountIds }, + ); const selectionOptions = { // Temporary switch drain keeps the candidate until the atomic claim rejects // it. Retained recovery makes main wholly ineligible so pool routing continues. @@ -911,13 +1012,21 @@ export async function resolveCodexAuthContext( ? () => preserveRequestOwnedMainPin : options.isMainAccountTokenLive, modelEligibleAccountIds, + deniedModelAccountIds, + // Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one + // conversation's attachments say nothing about where unrelated threads should be served. + retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true, }; // A pre-drain selector reserves the native identity while reconciliation and // routing inspect it. Selectors arriving after the fence skip reconciliation // and may still route to non-main pool accounts without touching switch state. if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError(); if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState(); - const resolution = fixedAccountId !== undefined + // Annotated, not inferred. The two literals below carry neither `affinity` nor + // `transientProbe`, so an inferred union makes `"k" in resolution` widen those reads to + // `unknown` and a discriminant narrowing fail outright. Contextually typing every branch to + // the resolver's own union is what lets the reads below stay total. + const resolution: CodexThreadResolution = fixedAccountId !== undefined ? { status: "selected" as const, accountId: fixedAccountId } : options.excludeAccountId ? (() => { @@ -942,8 +1051,17 @@ export async function resolveCodexAuthContext( lineage, ); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); + // THE REFUSAL. Every candidate is held, the recovery budget is spent, and no detour is + // left -- so this request must not reach upstream at all. Returning the held account here + // is what #4701 is about: under a provider-wide 503 that is every bound request piling + // onto an account already known to be failing. Thrown before any credential is read, so + // nothing is sent and nothing is spent. + if (resolution.status === "withheld") { + throw new CodexRecoveryWithheldError(resolution.accountId, resolution.retryAt, resolution.detourAccountId); + } const selected = resolution.status === "selected" ? resolution.accountId : null; - affinityDecision = "affinity" in resolution ? resolution.affinity : undefined; + affinityDecision = resolution.affinity; + transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined; if (!selected) { // A retry that excluded a failed Pool account may still use the validated caller-owned // main credential. Treating every exclusion as if main itself had failed strands a healthy @@ -1025,12 +1143,25 @@ export async function resolveCodexAuthContext( throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } } + } catch (cause) { + // Selection granted a trial and then a later policy check refused the account. The trial + // never runs, so hand it back instead of leaving the held account unprobeable until its + // deadline lapses (#4701). + releaseTransientProbeGrant(); + throw cause; } finally { selectionAdmission?.release(); } // Legacy selectors may retain an unusable account for actionable errors. A // deferred credential must never become request auth through that fallback. - assertCodexAccountValidationReady(accountId); + try { + assertCodexAccountValidationReady(accountId); + } catch (cause) { + // Nothing will reach upstream, so give the trial back instead of leaving the held account + // unprobeable until the lease deadline lapses (#4701). + releaseTransientProbeGrant(); + throw cause; + } // Lazy prime: if the selected account has no quota yet, the pool is likely // unprimed (dashboard never opened, or startup prime was blocked). Kick a // best-effort prime so the NEXT routing decision has real scores. This never @@ -1049,6 +1180,13 @@ export async function resolveCodexAuthContext( // a literal Retry-After reads very differently to a user than a reset-derived guess. const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope); const cooldownUntil = cooldown?.cooldownUntil; + // A transient-hold trial and a quota cooldown cannot both describe this account: + // `isTransientOnlyAffinityBlock` refuses to recognise a transient hold on an account carrying + // quota health, so the cooldown branch below is unreachable while a trial is held. That is + // also why no request pays two recovery permits for one send. The release is defensive -- + // should that invariant ever move, the trial is handed back rather than stranded behind a + // refusal that belongs to the other domain. + if (cooldownUntil && transientProbe) releaseTransientProbeGrant(); // A cooled-down account never sends traffic, so upstream recovery can never be // observed and the cooldown outlives the real limit. Admit one probe per // interval; its outcome decides whether the cooldown ends (#433). @@ -1089,6 +1227,7 @@ export async function resolveCodexAuthContext( if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter); assertMainAccountPolicy(policy); } catch (cause) { + releaseTransientProbeGrant(); if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); if (cause instanceof CodexMainAccountHardLockError) throw cause; @@ -1099,15 +1238,23 @@ export async function resolveCodexAuthContext( } if (!token) { // Nothing will reach upstream, so give the probe back instead of burning it. + releaseTransientProbeGrant(); if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); throw new CodexPoolAuthenticationError( fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined, ); } - const reserveAuthorization = reserve - ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration) - : undefined; + let reserveAuthorization: MainReserveAuthorization | undefined; + try { + reserveAuthorization = reserve + ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration) + : undefined; + } catch (cause) { + // A Reserve refusal ends the request here, so the trial it was holding never runs. + releaseTransientProbeGrant(); + throw cause; + } return { kind: "main-pool", accountId, @@ -1121,6 +1268,7 @@ export async function resolveCodexAuthContext( ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), + ...(transientProbe ? { transientProbe } : {}), }; } @@ -1141,8 +1289,10 @@ export async function resolveCodexAuthContext( ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), ...(affinityDecision ? { affinityDecision } : {}), + ...(transientProbe ? { transientProbe } : {}), }; } catch (cause) { + releaseTransientProbeGrant(); if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) { diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index f97b8295fc3..c842fe30460 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -34,7 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { catalogModelSlug } from "./parsing"; -import type { CatalogModel } from "./parsing"; +import type { CatalogModel, RawEntry } from "./parsing"; export const openAiApiCollisionWarnings = new Set(); @@ -222,6 +222,85 @@ export function safeCatalogWarningLabel(value: string): string { .slice(0, 200); } +/** + * Keep the first row of each slug and drop the rest (#4730). + * + * First-win is the only answer that agrees with the ordering already decided upstream: the merge + * ranks rows, so its first occurrence is the row it chose. Distinct slugs are never touched — an + * alias row and the canonical routed row of the same provider model are two different public names + * and both survive — and a row without a string slug passes through untouched. + */ +export function dedupeCatalogEntriesBySlug(models: RawEntry[]): RawEntry[] { + const seen = new Set(); + const out: RawEntry[] = []; + for (const entry of models) { + if (typeof entry.slug !== "string") { + out.push(entry); + continue; + } + if (seen.has(entry.slug)) continue; + seen.add(entry.slug); + out.push(entry); + } + return out; +} + +/** + * Every slug this proxy writes into the Codex catalog must appear exactly once (#4730). + * + * The cost of breaking it is the whole file: a slug-unique validating consumer refuses the catalog + * outright, so one duplicated row takes every model with it. A 2.56.0 report carried 507 rows for + * 72 unique slugs, every duplicate byte-identical, and Codex rejected the file as `source-invalid`. + * + * This is a write-boundary invariant rather than a repair of one producer, and that distinction is + * deliberate: the reported catalog is evidence that some emit path can double a row, but nothing in + * this tree has been shown to be that path, and a guard that only covered the producer someone + * guessed at would leave the file corruptible by the next one. Both writers that serialize a merged + * catalog call this as their LAST mutation — `writeRetainedCatalogSync` and the management + * convergence commit — so uniqueness holds for the exact bytes that land on disk. + * + * Ordering is load-bearing. Running the guard before the effort clamp would be unsound: + * `clampCatalogModelsToObservedCodexSupport` splices whole rows out when an exact-reserve ladder + * clamps empty, so dropping a later same-slug row first can leave the slug with no row at all once + * the surviving one is spliced. + * + * @param models - The finished row list, already clamped and finalized. + * @param warn - Whether to report on `console.warn`. The convergence path merges under + * `warningPolicy: "suppress"` and stays silent for the same reason. + * @returns The original array when it was already unique, so an unchanged catalog stays a no-op + * write; otherwise a first-win copy. + */ +export function enforceCatalogSlugUniqueness(models: RawEntry[], warn: boolean): RawEntry[] { + const deduped = dedupeCatalogEntriesBySlug(models); + if (deduped.length === models.length) return models; + if (warn) { + // A dropped row that differs from the kept one means two emit paths disagree about the same + // slug's content. First-win still stands, but the operator needs to see WHICH slugs diverged + // instead of silently losing data. The baseline is the row the dedupe actually keeps — the + // FIRST occurrence — so the reported divergence is measured against what lands on disk. + const keptBySlug = new Map(); + for (const entry of models) { + if (typeof entry.slug !== "string" || keptBySlug.has(entry.slug)) continue; + keptBySlug.set(entry.slug, entry); + } + const divergentSlugs = new Set(); + for (const entry of models) { + if (typeof entry.slug !== "string") continue; + const kept = keptBySlug.get(entry.slug); + if (kept && kept !== entry && JSON.stringify(kept) !== JSON.stringify(entry)) { + divergentSlugs.add(entry.slug); + } + } + const divergentNote = divergentSlugs.size > 0 + ? `; divergent content on: ${[...divergentSlugs].slice(0, 5).map(safeCatalogWarningLabel).join(", ")}${divergentSlugs.size > 5 ? ", …" : ""}` + : ""; + console.warn( + `[opencodex] catalog sync dropped ${models.length - deduped.length} duplicate slug row(s), keeping the first occurrence of each slug (#4730)${divergentNote}.`, + ); + } + return deduped; +} + export function comboCatalogWarningSignature( combo: NormalizedComboConfig, members: readonly CatalogModel[], diff --git a/src/codex/catalog/model-visibility.ts b/src/codex/catalog/model-visibility.ts index 0273a190527..8f628793845 100644 --- a/src/codex/catalog/model-visibility.ts +++ b/src/codex/catalog/model-visibility.ts @@ -290,6 +290,7 @@ export function filterCatalogVisibleModels( } return models.filter(m => { if (initialModelSelectionPending(config.providers[m.provider])) return false; + if (config.providers[m.provider]?.disabled === true) return false; const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true; // disabledModels may be stored raw (canonical) or encoded (legacy UI writes). for (const stored of disabled) { diff --git a/src/codex/catalog/remote.ts b/src/codex/catalog/remote.ts index 46576b18e43..9a6b534cdaa 100644 --- a/src/codex/catalog/remote.ts +++ b/src/codex/catalog/remote.ts @@ -113,12 +113,42 @@ function safeTimeout(value: number | undefined): number { ? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS; } +/** Match Bun fetch's environment routing, not the broader WebSocket NO_PROXY grammar. */ +function catalogRequestUsesBunHttpProxy(url: URL): boolean { + if (url.protocol !== "http:") return false; + const proxy = process.env.http_proxy || process.env.HTTP_PROXY; + if (!proxy || proxy === '""' || proxy === "''") return false; + const hostname = url.hostname.toLowerCase(); + const host = url.host.toLowerCase(); + // Bun env_loader::is_no_proxy (1.4.2): lowercase wins unless empty, ASCII + // whitespace only, no scheme/path/wildcard/bracket/trailing-dot normalization. + const bypasses = process.env.no_proxy || process.env.NO_PROXY || ""; + for (let entry of bypasses.split(",")) { + entry = entry.replace(/^[ \t\n\r\v\f]+|[ \t\n\r\v\f]+$/g, "") + .replace(/[A-Z]/g, letter => letter.toLowerCase()); + if (entry === "*") return false; + if (entry.startsWith(".")) entry = entry.slice(1); + if (!entry) continue; + const hasPort = entry.startsWith("[") + ? entry.includes("]:") + : (entry.match(/:/g)?.length ?? 0) === 1; + if (hasPort ? host === entry : hostname === entry || hostname.endsWith(`.${entry}`)) return false; + } + return true; +} + export async function fetchRemoteCatalog( input: string, options: Pick = {}, ): Promise<{ document: RemoteCatalogDocument; content: string }> { const url = validateRemoteCatalogUrl(input); const token = validateToken(options.token); + if (catalogRequestUsesBunHttpProxy(url)) { + throw new RemoteCatalogError( + "insecure_http_refused", + "Loopback HTTP catalog requests must bypass outbound HTTP proxy routing", + ); + } const headers = new Headers({ Accept: "application/json" }); if (token !== undefined) headers.set("Authorization", `Bearer ${token}`); let response: Response; diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 21daf32714f..8269d0fccd0 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -51,7 +51,7 @@ import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; import { clampCatalogModelsToCodexSupport } from "./effort"; import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch"; -import { exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation"; +import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation"; import { withCatalogWriteSerialization, type CatalogWritePermit, @@ -522,6 +522,9 @@ function writeRetainedCatalogSync({ }); clampCatalogModelsToCodexSupport(catalog.models); finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config); + // Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering + // against the effort clamp is load-bearing rather than cosmetic. + catalog.models = enforceCatalogSlugUniqueness(catalog.models, true); const added = goEntries.length + accountBoundEntries.length; const content = `${JSON.stringify(catalog, null, 2)}\n`; @@ -553,6 +556,11 @@ function writeRetainedCatalogSync({ }; } +// Re-exported so the #4730 unit regression keeps importing the guard from the sync module it +// guards; the implementation lives in ./aggregation because the management convergence commit +// is the second writer that has to apply the identical rule. +export { dedupeCatalogEntriesBySlug }; + export async function syncCatalogModels( config: OcxConfig, options?: CodexCatalogSyncOptions, diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 68dd1cc376a..f57bf76efc5 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -420,6 +420,42 @@ async function gatherRoutedModelsUncached( if (!memberByKey.has(key)) memberByKey.set(key, synthetic); } } + // [Decision Log] + // - 목적과 의도: combo derivation must see the same explicit custom-model capabilities that the + // final Models inventory publishes. Previously customModels were materialized only after this + // map had already derived every combo, so one row could say image while its combo said text. + // - 기존 구현 및 제약 조건: provider/discovery rows remain the inheritance source, and native + // OpenAI synthesis must run first so a sparse custom row cannot hide native hard limits. + // - 검토한 주요 대안: move the full custom-row materializer ahead of combos, or overlay only the + // explicit custom fields onto this private derivation map after provider/native inheritance. + // - 선택한 방식: use the scoped post-inheritance overlay; the existing final materializer stays + // the single owner of public custom-row construction and deduplication. + // - 다른 대안 대신 이 방식을 선택한 이유: moving the large materializer would reorder public + // catalog production and warning behavior, while this map is already private to combo input. + // - 장점, 단점 및 영향: custom context/modality/reasoning/tool-mode declarations now constrain + // their combos without widening unrelated rows; omitted fields retain provider/native limits. + for (const custom of config.customModels ?? []) { + const key = `${custom.provider}/${custom.modelId}`; + const inherited = memberByKey.get(key) ?? { + provider: custom.provider, + id: custom.modelId, + owned_by: custom.provider, + }; + memberByKey.set(key, { + ...inherited, + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + ...(typeof custom.contextWindow === "number" && custom.contextWindow > 0 + ? { contextWindow: custom.contextWindow } + : {}), + ...(Array.isArray(custom.inputModalities) + ? { inputModalities: [...custom.inputModalities] } + : {}), + ...(Array.isArray(custom.reasoningEfforts) + ? { reasoningEfforts: [...custom.reasoningEfforts] } + : {}), + ...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}), + }); + } // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and // custom-model vision-sidecar inheritance so both see the same merged registry view. const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); @@ -480,7 +516,8 @@ async function gatherRoutedModelsUncached( // with the same slug below, so that row's provider capability metadata is the inheritance source. const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); const customModels = (config.customModels ?? []).map(cm => { - const rawProvider = config.providers[cm.provider]; + const rawProvider = config.providers[cm.provider]?.disabled !== true + ? config.providers[cm.provider] : undefined; const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider; // Registry routing backfills an omitted authMode on the built-in OpenAI provider to // forward. Keep the catalog projection on the same contract while still failing closed diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index ffca581f5f7..e595205518c 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -587,8 +587,14 @@ export async function inspectCodexCliInstall( const platform = deps.platform ?? process.platform; const candidate = observeCodexRuntimeCandidateReadOnly(deps); if (!candidate) { + // This slice reads no candidate or configuration file on Windows, so an + // absent proof-captured environment candidate does not establish that no + // Codex CLI exists: a persisted selection is simply never consulted there. + // Report the deferral that actually happened instead of the stronger claim + // that the candidate is unavailable. POSIX retains its existing result + // when no candidate is observed. return isWindowsPlatform(platform) - ? unknownWindowsReport("candidate_unavailable") + ? unknownWindowsReport("windows_inspection_deferred") : unknownReport("candidate_unavailable"); } diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 9c844fad4a5..a8bb4b611b1 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -49,7 +49,7 @@ import { orderForSubagents, } from "./catalog/sync"; import { multiAgentV2EnabledFromConfigText } from "./features"; - import { exactComboCatalogSlugs } from "./catalog/aggregation"; + import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation"; import { isNativeAliasCatalogEntry, accountBoundNativeOpenAiSlugs, @@ -386,7 +386,12 @@ function prepareCatalog( : null, ); finalizeAutoReviewModelOverride(mergedModels, catalogModels, config); - catalog.models = mergedModels; + // The second writer of this file. A dashboard model toggle, a combo edit, or a Codex account + // login reaches `convergeCodexCatalog` and commits through `fixedCommit`, never through + // `writeRetainedCatalogSync`, so the #4730 uniqueness guard has to stand here too or the same + // `source-invalid` rejection returns by a different route. Silent because this merge runs under + // `warningPolicy: "suppress"`. + catalog.models = enforceCatalogSlugUniqueness(mergedModels, false); return catalog; } diff --git a/src/codex/desktop-app/types.ts b/src/codex/desktop-app/types.ts index 2a4d8774b4c..c90862f329b 100644 --- a/src/codex/desktop-app/types.ts +++ b/src/codex/desktop-app/types.ts @@ -116,11 +116,20 @@ export interface DesktopAppAdapter { * * `root` is expected to be `realpath`-resolved by discovery already. */ +function isMembershipSeparator(character: string): boolean { + // `/` separates on every platform this runs on, and Windows accepts it wherever it + // accepts `\`. `\` is only a separator where the host says so: it is a legal + // FILENAME character on POSIX, so admitting it there would reopen the sibling hole + // this function exists to close. + return character === "/" || (sep === "\\" && character === "\\"); +} + export function isUnderRoot(executable: string, root: string): boolean { if (!executable || !root) return false; if (executable === root) return true; - const prefix = root.endsWith(sep) ? root : root + sep; - return executable.startsWith(prefix); + if (!executable.startsWith(root)) return false; + if (isMembershipSeparator(root[root.length - 1]!)) return true; + return isMembershipSeparator(executable[root.length] ?? ""); } /** diff --git a/src/codex/desktop-app/windows.ts b/src/codex/desktop-app/windows.ts index 863a20057ab..c73e067e5b6 100644 --- a/src/codex/desktop-app/windows.ts +++ b/src/codex/desktop-app/windows.ts @@ -29,16 +29,16 @@ const SHELL_BASENAME = "chatgpt.exe"; const POWERSHELL_PROBE_OPTIONS = { timeout: PROBE_TIMEOUT_MS, windowsHide: true } as const; /** - * isUnderRoot prefixes with the host path.sep and is case-sensitive. Windows + * isUnderRoot checks a lexical path boundary and is case-sensitive. Windows * membership is case-insensitive, and this file is executed by Unix CI against - * backslash paths, so both sides are folded onto the host separator first. + * mixed slash paths, so both slash forms are folded onto the host separator first. * The boundary itself — sibling `OpenAI.Codex-evil` must not match root * `OpenAI.Codex` — is still isUnderRoot's, which is why the PowerShell * StartsWith is only a cheap pre-filter. */ function toHostMembershipPath(windowsPath: string): string { const lowered = windowsPath.toLowerCase(); - return sep === "\\" ? lowered : lowered.replaceAll("\\", "/"); + return lowered.replace(/[\\/]/g, sep); } function isMemberExecutable(executable: string, root: string): boolean { @@ -91,10 +91,10 @@ function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): De const literal = install.root.replace(/'/g, "''"); const script = [ "$ErrorActionPreference='SilentlyContinue'", - `$root = '${literal}'`, + `$root = '${literal}'.Replace('/', '\\')`, "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name", "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |", - " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |", + " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.Replace('/', '\\').StartsWith($root, 'OrdinalIgnoreCase') } |", " ForEach-Object {", " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner", " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {", diff --git a/src/codex/inject.ts b/src/codex/inject.ts index ea6424e0ebd..a01909a9c7b 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -465,9 +465,9 @@ async function injectCodexConfigImpl( */ /* * Re-observed inside the artifact transaction. A store that migrates to paginated history - * mid-write retires the relabel unit, because the config half writes no history and rolling - * it back is what left every paginated home with no OpenCodex models. Any other reason is - * still treated as a failed transition so compensation can restore the pre-images. + * mid-write can retire the relabel unit while its already-admitted candidate leaves + * existing provider references resolvable. Existing provider definitions are retained + * before the witness; no post-commit compensation may overwrite a newer native write. */ const observeHistoryRefusalOrThrow = (known: string | null): string | null => { if (known) return known; @@ -489,12 +489,12 @@ async function injectCodexConfigImpl( /* * Rows this home may have tagged `opencodex` resolve only through a provider table. Design B - * normally retires that table because the relabel migrates those rows back to `openai` in - * the same pass; with the relabel stood down, stripping it anyway would leave every such - * conversation pointing at a provider id that no longer exists. Keep what was already - * published, and keep it BEFORE the witness so the lock admits the bytes actually written. + * selects built-in `openai` for new work, but background relabel and native publication are + * not atomic. Codex can paginate after the final check or when the worker starts. Retain + * an existing definition BEFORE the witness regardless of preflight, so worker failure + * cannot orphan old references. Explicit restoration keeps its removal and history guards. */ - if (historyRelabelRefusal && hadOcxProviderTableOnDisk && !providerTableMode) { + if (hadOcxProviderTableOnDisk && !providerTableMode) { content = applyEol( content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})), eol, @@ -787,6 +787,7 @@ async function injectCodexConfigImpl( // handed down fixed; the Worker never takes a direction from its caller. // A stood-down relabel unit spawns no Worker: the preflight it would run first has // already refused, and the config half is committed either way. + historyArtifactStageForTests?.("before-history-worker"); const historyOutcome: CodexHistoryJobOutcome = historyRelabelRefusal ? { kind: "skipped" } : await runCodexHistoryJob({ @@ -984,4 +985,3 @@ export { setBeforeRestoreConfigForTests, skippedRestoreEnvelope, } from "./inject/restore"; - diff --git a/src/codex/inject/restore.ts b/src/codex/inject/restore.ts index 15282ea7716..5e273173ece 100644 --- a/src/codex/inject/restore.ts +++ b/src/codex/inject/restore.ts @@ -89,6 +89,18 @@ export interface CodexNativeRestoreResult { success: boolean; message: string; externalProvider?: string; + /** + * Set when the restore refused at the Codex history preflight (#4718). + * + * The preflight runs before the config half, so a refusal leaves config, catalog, + * history and provenance exactly as they were. That is a different outcome from a + * restore that ran and failed, and callers that decide whether an obligation was + * discharged need to tell them apart. Reading the artifact states alone cannot: a + * refusal reports every artifact as `skipped`, which is also what an ownership refusal + * and a desired-state skip report. Matching the human-readable message instead would + * make a safety decision depend on prose. + */ + historyPreflightRefusal?: string; artifacts: { config: CodexRestoreConfigResult; catalog: CodexRestoreCatalogResult; @@ -216,6 +228,21 @@ function failedConfigRestoreEnvelope(config: CodexRestoreConfigResult): CodexNat return result; } +/** + * The history preflight refused, so nothing was attempted at all (#4718). + * + * The message is unchanged from what this path has always printed; the structured reason + * is added beside it so a caller can act on the refusal without reading the prose. + */ +function historyPreflightRefusalEnvelope(historyError: string): CodexNativeRestoreResult { + const result = skippedRestoreEnvelope( + false, + `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`, + ); + result.historyPreflightRefusal = historyError; + return result; +} + /** The config/profile half of a native restore, reported as one artifact. */ function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult { const preImages = captureCodexPreImages(); @@ -342,7 +369,7 @@ async function restoreNativeCodexAsyncImpl( } const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); + if (historyError) return historyPreflightRefusalEnvelope(historyError); const eligibility = codexWriteCoordinationEligibility({ coordinatorPath: () => @@ -490,7 +517,7 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD return desiredEnabledRestoreSkip(); } const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); + if (historyError) return historyPreflightRefusalEnvelope(historyError); // Captured before the config half: a successful journal restore DELETES the journal, and // restoring the config can drop `model_catalog_json`. Either one would hide the routed // catalog we actually wrote (#1798). diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 790d598eaf7..a770925f1d6 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -1,15 +1,18 @@ import { createHash } from "node:crypto"; import { readBoundedResponseBody } from "../lib/bounded-body"; -import type { OcxConfig } from "../types"; +import type { CodexAccountCredentialRecord, OcxConfig } from "../types"; import { isSelectableCodexPoolAccount } from "./account-id"; -import { getValidCodexToken, readCodexAccountRecord } from "./account-store"; +import { getValidCodexToken, loadCodexAccountRecordSnapshot } from "./account-store"; import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, type NativeMainRefreshDependencies, } from "./main-account"; -import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; +import { + ACCOUNT_GATED_NATIVE_OPENAI_MODELS, + NATIVE_GPT6_ASTRA_MODEL, +} from "./catalog/native-models"; import { loadPersistedCodexRuntime } from "./runtime"; import { codexRuntimeStateEpoch } from "./runtime"; import upstreamModelsSnapshot from "./data/upstream-models.json"; @@ -520,17 +523,48 @@ function boundedCacheSet(accountId: string, value: CachedAccountModels): void { evictClass(accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)); } +/** + * An identity resolver scoped to one caller's pass, reading each backing store at most once. + * + * The identity check itself is unchanged -- same prefix rule, same tombstone and missing-credential + * rejection, same `pool::` shape -- but the READ is hoisted. Per-id + * resolution reloads and reparses the whole `codex-accounts.json` every call, so a loop over cache + * entries paid one full-store read per entry: the denial reader admits 64 accounts with four client + * versions each, which is up to 256 synchronous reads to score a single warm flagship request. + * + * Both stores are read lazily, so a pass that touches only Direct callers, or only native main, + * still opens nothing it does not need. Neither backing read is memoized across passes: a resolver + * lives for one synchronous loop, and that loop has no suspension point, so nothing this process + * does can change the file underneath it. A snapshot is therefore not staler than per-entry reads + * would have been -- it is strictly more coherent, because a foreign writer landing mid-loop can no + * longer give the earlier entries one generation and the later ones another. + */ +function credentialIdentityResolver(): (accountId: string) => string | undefined { + let records: Readonly> | undefined; + let mainRead = false; + let mainIdentity: string | undefined; + return (accountId: string): string | undefined => { + if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) { + return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`; + } + if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (!mainRead) { + const token = getMainAccountToken(); + mainIdentity = token ? `main:${token.chatgptAccountId}` : undefined; + mainRead = true; + } + return mainIdentity; + } + records ??= loadCodexAccountRecordSnapshot(); + const record = records[accountId]; + if (!record?.credential || record.deletedAt != null) return undefined; + return `pool:${record.generation}:${record.credential.chatgptAccountId}`; + }; +} + +/** Single-id resolution. Identical to one call through a fresh {@link credentialIdentityResolver}. */ function currentCredentialIdentity(accountId: string): string | undefined { - if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) { - return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`; - } - if (accountId === MAIN_CODEX_ACCOUNT_ID) { - const token = getMainAccountToken(); - return token ? `main:${token.chatgptAccountId}` : undefined; - } - const record = readCodexAccountRecord(accountId); - if (!record?.credential || record.deletedAt != null) return undefined; - return `pool:${record.generation}:${record.credential.chatgptAccountId}`; + return credentialIdentityResolver()(accountId); } async function accountCredentialSnapshot( @@ -926,8 +960,10 @@ export async function ensureCodexEntitlementFreshness( ); const candidates = normalizedCandidateAccountIds(config); const mutationEpoch = codexCredentialMutationEpoch(); + // Same hoist as the denial pass: this prologue is synchronous and reads once per candidate. + const identityOf = credentialIdentityResolver(); const identityEntries = candidates.map(accountId => ( - [accountId, currentCredentialIdentity(accountId) ?? null] as const + [accountId, identityOf(accountId) ?? null] as const )); const identityVector = new Map(identityEntries); const workset = candidates.filter(accountId => needsEntitlementRefresh( @@ -980,8 +1016,9 @@ export function getCodexModelEntitlementStatus( clientVersion?: string | null, ): CodexModelEntitlementStatus { const version = resolveCodexEntitlementClientVersion(clientVersion); + const identityOf = credentialIdentityResolver(); const accounts = candidateAccountIds(config).flatMap(accountId => { - const credentialIdentity = currentCredentialIdentity(accountId); + const credentialIdentity = identityOf(accountId); return credentialIdentity ? [{ accountId, credentialIdentity }] : []; }); if (accounts.length === 0) return { status: "unavailable" }; @@ -1159,6 +1196,101 @@ export function availableAccountGatedNativeModels( ))); } +/** + * Native models that stay unconditionally VISIBLE while their per-account availability still + * varies. + * + * This is deliberately not `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` and must never become it. That set + * fails closed on ABSENCE of evidence: membership hides the row from the catalog and refuses the + * request before dispatch, which is exactly what the owner decision of 2026-09-04 removed the + * flagships from. A timed-out fetch or a shard that has not caught up would make the model vanish + * from the picker, and "opencodex lost my model" is a worse failure than one upstream 400. + * + * This set carries the opposite polarity. It admits only a CONFIRMED DENIAL as evidence, and it + * feeds an ordering preference rather than a refusal, so absent or stale evidence changes nothing. + * That is the distinction #4768 asked for: a pool holding a Plus account and a Free account should + * stop handing Sol/Astra to the Free account whose own authenticated roster already says it cannot + * serve them, without gating the model on evidence that may never arrive. + */ +export const ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + NATIVE_GPT6_ASTRA_MODEL, +]); + +/** + * Accounts whose OWN authenticated roster definitively omits `modelId`, read synchronously from + * evidence discovery has already gathered. + * + * Synchronous and cache-only by contract. The gated path may await `resolveCodexModelEntitlements` + * because a gated model is rare and already pays a bounded discovery call; the flagships are the + * most commonly requested models in the product, and putting an authenticated upstream fetch per + * account on that request path would trade one occasional 400 for latency on every turn. The cache + * this reads is warmed anyway: `modelsForCredential` stores each account's FULL roster, and + * background catalog sync (`src/codex/catalog/retained-sync.ts`) and convergence already resolve + * entitlements for every pool account. + * + * Returns `undefined` rather than an empty set when nothing is denied, so a caller cannot confuse + * "no account is denied" with "no evidence exists" — both mean the same thing here, which is that + * selection must be left exactly as it was. + * + * Only `denied` counts. `unknown` covers an unconfirmed account, a roster fetched under a client + * version too old to return the model, and an expired or credential-stale entry; none of those is + * proof that the account lacks the model, and treating them as proof is how 2.36.0 removed + * sol/terra/luna from accounts that owned them (#3022). + */ +export function cachedDeniedCodexAccountIdsForModel( + modelId: string | undefined, + now = Date.now(), + options: { excludeAccountIds?: ReadonlySet } = {}, +): ReadonlySet | undefined { + if (!modelId || !ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined; + const denied = new Set(); + const granted = new Set(); + // One resolver for the whole pass: the loop below runs once per cached (account, client version) + // entry, and resolving an identity per entry meant a full account-store read per entry. + const identityOf = credentialIdentityResolver(); + for (const [key, entry] of accountModelsCache) { + const accountId = accountIdOfCacheKey(key); + // A forwarded Direct credential is one request's caller, never a pool candidate. + if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) continue; + // The caller's read fence, honoured BEFORE `identityOf` below, because that is the read: for + // native main it resolves the physical stored token. A request that is forbidden to read main + // -- a profile switch draining it, or a request-owned credential that owns no main state -- + // must not reread account storage just to score an ordering preference. Dropping the account + // leaves it UNKNOWN rather than denied, which is the same outcome as having no cached roster + // for it and changes no selection. The resolver reads lazily for the same reason: an excluded + // account `continue`s here, so its store is never opened at all. + if (options.excludeAccountIds?.has(accountId)) continue; + if (entry.expiresAt <= now) continue; + // A credential we can currently read AND that differs is proof the entry answers for a + // different account than this id now names, so its denial is not evidence about the current + // one. An UNREADABLE credential is not proof of anything, and the same unknown-is-not-denied + // discipline that governs rosters governs identities: it leaves the entry in place rather + // than manufacturing a reason to ignore it. + const identity = identityOf(accountId); + if (identity !== undefined && identity !== entry.credentialIdentity) continue; + const state = codexModelEntitlementStateForRoster( + entry.models, + entry.confirmed, + entry.clientVersion, + modelId, + ); + if (state === "granted") granted.add(accountId); + else if (state === "denied") denied.add(accountId); + } + // One account holds one entry per client version, and upstream filters the roster by that + // version. So the same account can legitimately carry a granted entry under a current client + // and a denied one under an older client that predates the model. Positive evidence is + // authoritative regardless of which version asked for it -- the same rule + // `codexModelEntitlementStateForRoster` applies within a single entry -- so a grant anywhere + // clears the denial rather than being outvoted by whichever entry the map happened to yield + // last. + for (const accountId of granted) denied.delete(accountId); + return denied.size > 0 ? denied : undefined; +} + /** Synchronous projection for management/catalog readers after a discovery pass. */ export function cachedAvailableAccountGatedNativeModels( now = Date.now(), @@ -1192,6 +1324,11 @@ export function cachedAvailableAccountGatedNativeModels( export function isCodexModelEntitlementSnapshotCurrent(snapshot: CodexModelEntitlementSnapshot): boolean { for (const [accountId, identity] of snapshot.credentialIdentities) { + // Deliberately per-id, unlike the passes above. This is a fail-closed publication gate asking + // whether a snapshot is STILL current, so the freshest possible answer per account is the + // point of the read. A pass-wide snapshot would be a coherence win everywhere else and a + // small weakening here: it could answer "current" for a later account from a record a + // concurrent reauth had already replaced. if (currentCredentialIdentity(accountId) !== identity) return false; } return true; diff --git a/src/codex/pool-refresh-backoff.ts b/src/codex/pool-refresh-backoff.ts index d9474ceb215..acbe2133891 100644 --- a/src/codex/pool-refresh-backoff.ts +++ b/src/codex/pool-refresh-backoff.ts @@ -43,6 +43,12 @@ const backoffByAccount = new Map(); * must not re-quarantine the credential that replaced it. */ const fenceByAccount = new Map(); +/** + * Invalidates every account fence without having to know which refresh flights are currently in + * progress. A bulk routing-state reset can race a first failure for an account that has no map + * entry yet, so iterating either map cannot close this boundary. + */ +let globalFence = 0; let nowOverride: number | undefined; export function setCodexPoolRefreshFailureNowForTests(now?: number): void { @@ -52,12 +58,13 @@ export function setCodexPoolRefreshFailureNowForTests(now?: number): void { export function resetCodexPoolRefreshFailureBackoffForTests(): void { backoffByAccount.clear(); fenceByAccount.clear(); + globalFence = 0; nowOverride = undefined; } /** The value a refresh flight captures before it starts, to be handed back on failure. */ -export function codexPoolRefreshFence(accountId: string): number { - return fenceByAccount.get(accountId) ?? 0; +export function codexPoolRefreshFence(accountId: string): string { + return `${globalFence}:${fenceByAccount.get(accountId) ?? 0}`; } export function clearCodexPoolRefreshFailure(accountId: string): void { @@ -72,6 +79,8 @@ export function clearCodexPoolRefreshFailure(accountId: string): void { */ export function clearAllCodexPoolRefreshFailures(): void { backoffByAccount.clear(); + fenceByAccount.clear(); + globalFence += 1; } function currentNow(now?: number): number { @@ -115,7 +124,7 @@ export function noteCodexPoolRefreshFailure( accountId: string, reason: string, now = currentNow(), - fence?: number, + fence?: string, ): { consecutiveFailures: number; cooldownUntil: number; openedWindow: boolean } { const existing = backoffByAccount.get(accountId); // A flight that started before the account's failures were cleared is speaking for a grant diff --git a/src/codex/quota-rejection.ts b/src/codex/quota-rejection.ts index 39c5389254a..cde0a8272d1 100644 --- a/src/codex/quota-rejection.ts +++ b/src/codex/quota-rejection.ts @@ -8,8 +8,41 @@ const RESET_ELIGIBLE_CODE_VALUES = [ export type CodexResetEligibleExhaustionCode = (typeof RESET_ELIGIBLE_CODE_VALUES)[number]; +/** + * Upstream codes that name an ORGANIZATION- or PROJECT-scoped exhaustion (#4546). + * + * These are a different animal from the reset-eligible codes above, and the difference is the + * whole point. `usage_limit_exceeded` describes the account that was asked; another pool account + * carries its own plan allowance, so rotating to it is a real move. Every code here describes a + * limit the CREDENTIAL does not own -- a balance, a spend cap, or a usage cap held by the + * organization or project the credential belongs to. Two credentials inside that organization + * are refused by the same counter, so rotating between them pays a cold prompt prefix for zero + * new capacity, which is the send amplification this unit exists to stop. + * + * openai/codex reached the same classification from the client side: #44492 maps exactly these + * HTTP 429 codes to a terminal `QuotaExceeded` instead of a retry-limit failure, and #45602 + * extends it to the SSE path while deliberately KEEPING `rate_limit_exceeded` and `slow_down` + * retryable. The platform documentation states the same rule for the whole class: "It does not + * mean that quota, billing, or other errors that require user action can be resolved by + * retrying." + * + * Membership here says nothing about reset credits. A reset credit reconciles a ChatGPT plan + * window; it cannot pay an organization's bill, so these codes are deliberately absent from + * {@link RESET_ELIGIBLE_CODE_VALUES} and never set `resetCreditEligible`. + */ +const SCOPED_EXHAUSTION_CODE_VALUES = [ + "credit_balance_exhausted", + "organization_spend_limit_exceeded", + "project_spend_limit_exceeded", + "organization_usage_limit_exceeded", +] as const; + +export type CodexScopedExhaustionCode = + (typeof SCOPED_EXHAUSTION_CODE_VALUES)[number]; + export type CodexPreStreamRejectionKind = | "reset-eligible-exhaustion" + | "scoped-quota-exhaustion" | "generic-rate-limit" | "unverified-billing-or-quota" | "transient-server-error" @@ -23,6 +56,12 @@ export interface CodexPreStreamRejection { alternateRetryEligible: boolean; resetCreditEligible: boolean; semanticCode?: CodexResetEligibleExhaustionCode; + /** + * The organization- or project-scoped exhaustion code the upstream body named, when it named + * one. Never accompanied by `semanticCode`: the two sets are disjoint, and only `semanticCode` + * may authorize a reset credit. + */ + scopedExhaustionCode?: CodexScopedExhaustionCode; /** * Structured denial evidence for a 403. Present only when the upstream body names a * workspace/entitlement denial, which proves the CREDENTIAL is valid and the account @@ -97,6 +136,7 @@ function structuredDenialCode(payload: unknown): string | undefined { } const RESET_ELIGIBLE_CODES: ReadonlySet = new Set(RESET_ELIGIBLE_CODE_VALUES); +const SCOPED_EXHAUSTION_CODES: ReadonlySet = new Set(SCOPED_EXHAUSTION_CODE_VALUES); const TRANSIENT_SERVER_STATUSES = new Set([500, 502, 503, 504, 520, 521, 522]); const JSON_NUMBER_PATTERN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y; @@ -107,6 +147,7 @@ function rejection( options: { alternateRetryEligible?: boolean; semanticCode?: CodexResetEligibleExhaustionCode; + scopedExhaustionCode?: CodexScopedExhaustionCode; } = {}, ): CodexPreStreamRejection { return { @@ -115,6 +156,7 @@ function rejection( alternateRetryEligible: options.alternateRetryEligible === true, resetCreditEligible: options.semanticCode !== undefined, ...(options.semanticCode ? { semanticCode: options.semanticCode } : {}), + ...(options.scopedExhaustionCode ? { scopedExhaustionCode: options.scopedExhaustionCode } : {}), }; } @@ -210,9 +252,19 @@ function isUnsafeJsonDocument(text: string): boolean { } } -function exactResetEligibleCode( +/** + * Read the one exact, unambiguous code a container declares, and only if it is in `allowed`. + * + * Generic over the allowed set so the reset-eligible and organization-scoped classifications + * share one parser. They must: the strictness here -- a `code`/`type` pair that disagrees is + * rejected rather than resolved, and no trimming or case folding is applied -- is what keeps a + * near-miss from being read as an exact upstream code, and a second hand-written copy would + * drift away from that. + */ +function exactAllowedCode( container: Record, -): CodexResetEligibleExhaustionCode | undefined { + allowed: ReadonlySet, +): string | undefined { const hasCode = hasOwnField(container, "code"); const hasType = hasOwnField(container, "type"); if (!hasCode && !hasType) return undefined; @@ -226,49 +278,80 @@ function exactResetEligibleCode( const value = hasCode ? code : type; if (typeof value !== "string") return undefined; - return RESET_ELIGIBLE_CODES.has(value as CodexResetEligibleExhaustionCode) - ? value as CodexResetEligibleExhaustionCode - : undefined; + return allowed.has(value) ? value : undefined; } -function structuredResetEligibleCode(payload: unknown): CodexResetEligibleExhaustionCode | undefined { +function structuredAllowedCode(payload: unknown, allowed: ReadonlySet): string | undefined { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; const root = payload as Record; const hasRootDiscriminator = hasOwnField(root, "code") || hasOwnField(root, "type"); - if (!hasOwnField(root, "error")) return exactResetEligibleCode(root); + if (!hasOwnField(root, "error")) return exactAllowedCode(root, allowed); if (hasRootDiscriminator) return undefined; const nested = root.error; if (!nested || typeof nested !== "object" || Array.isArray(nested)) return undefined; - return exactResetEligibleCode(nested as Record); + return exactAllowedCode(nested as Record, allowed); } -async function resetEligibleCodeFromResponse( +/** + * Parse one bounded body and classify its structured code against both sets at once. + * + * One read, because the caller holds a `Response` whose body may only be consumed once per + * clone and the two questions are asked about the same bytes. + */ +async function exhaustionCodeFromResponse( response: Response, signal?: AbortSignal, -): Promise { +): Promise<{ + resetEligible?: CodexResetEligibleExhaustionCode; + scoped?: CodexScopedExhaustionCode; +}> { try { const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true }); - if (!body.displaySafe || body.truncated || !body.text.trim()) return undefined; + if (!body.displaySafe || body.truncated || !body.text.trim()) return {}; const payload = JSON.parse(body.text) as unknown; // JSON.parse silently keeps the last duplicate key, making contradictory // payloads order-dependent. Reject any duplicate at any object depth. - if (isUnsafeJsonDocument(body.text)) return undefined; - return structuredResetEligibleCode(payload); + if (isUnsafeJsonDocument(body.text)) return {}; + const resetEligible = structuredAllowedCode(payload, RESET_ELIGIBLE_CODES); + if (resetEligible !== undefined) { + return { resetEligible: resetEligible as CodexResetEligibleExhaustionCode }; + } + const scoped = structuredAllowedCode(payload, SCOPED_EXHAUSTION_CODES); + return scoped === undefined ? {} : { scoped: scoped as CodexScopedExhaustionCode }; } catch { // Classification must fail closed. A malformed, oversized, consumed, or // cancelled body cannot authorize an irreversible reset-credit operation. - return undefined; + return {}; } } +/** + * The organization- or project-scoped exhaustion code this rejection names, if any. + * + * Exported for the account-rotation gate, which has to answer "may another pool account serve + * this?" before it has any reason to build a full classification. Fails closed to `undefined`: + * an unreadable, truncated, duplicate-keyed or aborted body leaves the caller's existing + * behaviour untouched, so only positive evidence can ever withhold a rotation. + */ +export async function codexScopedExhaustionCode( + response: Response, + options: { signal?: AbortSignal } = {}, +): Promise { + return (await exhaustionCodeFromResponse(response, options.signal)).scoped; +} + /** * Classify an upstream Codex rejection before any response event is exposed. * * Only an exact structured exhaustion code on HTTP 429/402 is reset-eligible. * Status alone and message text are intentionally insufficient. The broad * alternate-account retry remains eligible for 429/402 to preserve #584. + * + * The one carve-out from that breadth is an organization- or project-scoped exhaustion + * ({@link SCOPED_EXHAUSTION_CODE_VALUES}), which reports `alternateRetryEligible: false` + * because every credential inside the refusing limit would be refused by the same counter. */ export async function classifyCodexPreStreamRejection( response: Response, @@ -283,13 +366,19 @@ export async function classifyCodexPreStreamRejection( if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error"); if (status !== 429 && status !== 402) return rejection(status, "other"); - const semanticCode = await resetEligibleCodeFromResponse(response, options.signal); + const { resetEligible: semanticCode, scoped } = await exhaustionCodeFromResponse( + response, + options.signal, + ); if (semanticCode) { return rejection(status, "reset-eligible-exhaustion", { alternateRetryEligible: true, semanticCode, }); } + if (scoped) { + return rejection(status, "scoped-quota-exhaustion", { scopedExhaustionCode: scoped }); + } return rejection( status, status === 429 ? "generic-rate-limit" : "unverified-billing-or-quota", diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 0a9a0b9c3c8..5f768f554b1 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -54,6 +54,13 @@ import { type CodexUpstreamHealth, } from "./routing/health-store"; import { ownsProbeLease, probeMayClearCooldown, withProbeLeaseReleased } from "./routing/probe-lease"; +// `./routing/probe-lease` above is the QUOTA-COOLDOWN lease; the module below owns the +// unrelated TRANSIENT-HOLD trial and the pool-wide recovery bound above it (#4701). +import { + isTransientHoldExpired, + resolveTransientHoldDispatch, + settleTransientProbeForOutcome, +} from "./routing/transient-hold-dispatch"; import { adoptLegacyLineageAffinity, affinityAfterRelease, @@ -85,7 +92,6 @@ import { getPoolAccountPlanForSelection, hasCodexQuotaHeadroom, isCodexAccountPlanExcluded, - isCacheAffinityEnabled, isCodexAccountSelectable, isHealthySharedCodexSelection, isUnknownUsage, @@ -96,11 +102,13 @@ import { pickPriorityPreemption, pickResetFirstCodexAccount, pickUnboundStrategyAccount, + preferModelEntitledAccount, sharedStateSelectionOptions, strategySelectionOptionsForModelDetour, shouldFailover, peekAlternateCodexAccount, } from "./routing/selection"; +import { mayRebindAffinityForQuota } from "./routing/cache-affinity"; import { clearAllManualPreferences, consumeManualPreference, @@ -181,6 +189,7 @@ export type { CodexAffinityMove, CodexAffinityReason, CodexAffinityDecision, + TransientProbeGrant, } from "./routing/thread-affinity"; export { isCodexAccountPlanExcluded, @@ -276,12 +285,6 @@ function isTransientOnlyAffinityBlock( || isCodexPoolRefreshCooling(entry.accountId, now); } -/** Has a held binding waited longer than a transient failure can reasonably explain? */ -function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolean { - return entry.transientHoldSince !== undefined - && now - entry.transientHoldSince > CODEX_TRANSIENT_AFFINITY_HOLD_MS; -} - /** * Is every pin this thread holds on the failing account past its hold window? * @@ -477,6 +480,8 @@ export function resolveCodexAccountForThread( lineage?: CodexThreadLineage, ): string | null { const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now, quotaScope, undefined, undefined, lineage); + // A WITHHELD dispatch is deliberately not an account here: this wrapper cannot carry a retry + // time, and answering with the held account is the send the hold prevents. Fails closed. return resolution.status === "selected" ? resolution.accountId : null; } @@ -583,34 +588,6 @@ function previewReusableAffinityAccount( return entry.accountId; } -/** - * May a LIVE binding be moved for quota reasons? - * - * Default: no. The bar is genuine exhaustion, because moving a bound conversation discards - * the prompt cache warmed on its account and a threshold crossing is a hint that the account - * is getting busy rather than evidence it cannot serve (#4546). Deliberately NOT - * `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the - * old rule under a new name. - * - * With `pool.cacheAffinity: false` the historical rule comes back: a crossing of - * `autoSwitchThreshold` is enough. That is capacity-first routing, and an operator who wants - * it keeps it -- but it is no longer what an install gets by never having heard of the flag. - */ -function mayRebindAffinityForQuota( - config: OcxConfig, - accountId: string, - usage: number, - threshold: number, - selectionOptions?: CodexAccountUsabilityOptions, -): boolean { - const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; - if (!isCacheAffinityEnabled(config)) return overThreshold; - // The usable half is already guaranteed by both callers, which gate on - // isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own. - return !isCodexAccountUsable(config, accountId, selectionOptions) - || (!isUnknownUsage(usage) && usage >= 100); -} - /** Reset ordering may move a binding only under the existing cache-affinity release policy. */ function resetFirstAffinityReplacement( entry: ThreadAffinityEntry, @@ -843,6 +820,9 @@ export function previewCodexAccountForRequest( const best = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions); if (best) active = best; } + // Same correction resolve applies, for the same reason: preview must name the account the + // request will actually use, or subagent fallback scores a model against the wrong one. + active = preferModelEntitledAccount(config, active, now, quotaScope, selectionOptions); if (!isCodexAccountUsable(config, active, selectionOptions)) { return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null; } @@ -936,15 +916,12 @@ export function resolveCodexAccountForThreadDetailed( const lane = transientDetourAccount(config, detourEntry, now, quotaScope, selectionOptions); detourEntry.transientHoldSince ??= now; detourEntry.lastUsedAt = now; - if (lane !== null && lane !== detourEntry.accountId) { - detourEntry.transientDetourAccountId = lane; - return { status: "selected", accountId: lane, affinity: { move: "detour", reason: "transient" } }; - } // A provider-wide outage soft-avoids every sibling, so there is nowhere to detour. // That is a statement about where this request can go, not about who owns the // conversation: dropping the pin here would rebuild the cold prefix elsewhere for - // exactly the failure mode the hold exists to survive. - return { status: "selected", accountId: detourEntry.accountId, affinity: { move: "held", reason: "transient" } }; + // exactly the failure the hold exists to survive -- nor a licence to send at the + // failing account, which is what the dispatch resolver bounds (#4701). + return resolveTransientHoldDispatch(detourEntry, lane, now); } // Detour expiry or invalidation must not expire the ordinary task. Drop only // this model lane and select from ordinary/shared state below. @@ -1018,16 +995,11 @@ export function resolveCodexAccountForThreadDetailed( const detour = transientDetourAccount(config, entry, now, quotaScope, selectionOptions); entry.transientHoldSince ??= now; entry.lastUsedAt = now; - if (detour !== null && detour !== entry.accountId) { - entry.transientDetourAccountId = detour; - // Deliberately no promoteActiveCodexAccount and no rebind: this is one request routing - // around a blip, not the pool deciding where the conversation now lives. - return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } }; - } // No sibling can take it either -- the usual shape of a provider-wide 503. The binding // survives: "cannot send right now" and "forget which account owns this conversation" - // are different answers, and conflating them is what the hold was added to stop. - return { status: "selected", accountId: entry.accountId, affinity: { move: "held", reason: "transient" } }; + // are different answers. So is the third answer this used to give -- "send at the + // failing account" -- now a bounded probe or a typed refusal (#4701). + return resolveTransientHoldDispatch(entry, detour, now); } // A model-only exclusion does not invalidate the shared task binding. Health, // generation, pause, cooldown, and failure evidence still retire it normally. @@ -1241,6 +1213,10 @@ export function resolveCodexAccountForThreadDetailed( selectionOptions, !preserveSharedSelectionForModelDetour, ); + // The shared cursor can name an account whose own roster denies this model, and an active + // account never passes through the eligible list. Correct it for THIS request only -- nothing + // is persisted -- and only toward an account eligibility already admitted (#4768). + active = preferModelEntitledAccount(config, active, now, quotaScope, selectionOptions); if (!isCodexAccountUsable(config, active, selectionOptions)) { return hasConfiguredPoolAccount(config, active, selectionOptions) ? { status: "selected", accountId: active, affinity: affinityAfterRelease(threadId, releaseReason) } @@ -1277,6 +1253,10 @@ export function recordCodexUpstreamOutcome( recordUpstreamHostFailure(meta.hostKey, { code: meta.lastFailureCode, now: meta.now ?? Date.now() }); } if (!accountId) return; + // Conclude the half-open recovery trial BEFORE the admissibility gate below (#4701): an + // outcome that gate drops still ended this request, and a lease nobody hands back leaves the + // next trial waiting out its deadline. The settle carries its own fences, so this is safe here. + settleTransientProbeForOutcome(accountId, meta, classifyCodexUpstreamOutcome(outcome, meta.denial)); const writerGeneration = meta.writerGeneration ?? captureConfigGeneration(); if (!isHealthAccountAdmissible(accountId, writerGeneration)) return; const now = meta.now ?? Date.now(); diff --git a/src/codex/routing/cache-affinity.ts b/src/codex/routing/cache-affinity.ts new file mode 100644 index 00000000000..6147577689d --- /dev/null +++ b/src/codex/routing/cache-affinity.ts @@ -0,0 +1,70 @@ +import type { OcxConfig } from "../../types"; +import type { CodexAccountUsabilityOptions } from "../account-usability"; +import { isCodexAccountUsable } from "../account-usability"; +import { isCacheAffinityEnabled, isUnknownUsage } from "./selection"; + +/** + * Does a healthy bound account keep its conversation when quota re-evaluation looks at it? + * + * Two independent reasons say yes, and they are not the same claim. `pool.cacheAffinity` is an + * operator preference about COST: provider prompt caches are account-isolated, so handing a bound + * conversation from account to account re-sends the whole prefix, and #4546 measured 7k-token + * turns becoming 150k-token ones. Setting it false restores capacity-first routing, and an + * operator who wants that keeps it. + * + * Uploaded-file retention is a claim about CORRECTNESS, so it does not take that instruction + * (#4778). Uploaded files are scoped to the account that issued them. Moving a conversation that + * carries live `file_id` references does not cost a cold prefix -- it orphans the reference, and + * because the reference stays in conversation history EVERY later turn is refused with + * `409 account_change_file_scope` until the user re-uploads under the serving account or starts + * over. That is a dead conversation rather than an expensive one, and `pool.cacheAffinity: false` + * was never asking to accept it: the flag trades cache locality for capacity, not correctness for + * capacity. + * + * This answers the VOLUNTARY move only. Its caller still releases the binding on genuine + * exhaustion or an unusable account, and every involuntary release that runs earlier in + * `resolveCodexAccountForThreadDetailed` -- quota refusal, failover streak, pause, cooldown, lost + * generation, affinity expiry -- never reaches here at all. So retention can never wedge a + * conversation on an account that cannot serve it, which is exactly why the #4710 refusal remains + * required: this makes that refusal rarer and does not replace it. + * + * It lives beside selection rather than inside `routing.ts` because it is a policy question two + * call sites ask -- the live path in `reevaluateAffinityQuota` and the side-effect-free + * `previewReusableAffinityAccount` that subagent fallback reads -- and those two must answer + * identically or preview hands fallback a different account than the request uses. + */ +export function retainsBoundAccountForQuota( + config: OcxConfig, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { + return isCacheAffinityEnabled(config) + || selectionOptions?.retainAccountForUploadedFiles === true; +} + +/** + * May a LIVE binding be moved for quota reasons? + * + * Default: no. The bar is genuine exhaustion, because moving a bound conversation discards the + * prompt cache warmed on its account and a threshold crossing is a hint that the account is + * getting busy rather than evidence it cannot serve (#4546). Deliberately NOT + * `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the old + * rule under a new name. + * + * When nothing retains, the historical rule comes back: a crossing of `autoSwitchThreshold` is + * enough. That is capacity-first routing, and an operator who asks for it keeps it -- it is just + * not what an install gets by never having heard of the flag. + */ +export function mayRebindAffinityForQuota( + config: OcxConfig, + accountId: string, + usage: number, + threshold: number, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { + const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; + if (!retainsBoundAccountForQuota(config, selectionOptions)) return overThreshold; + // The usable half is already guaranteed by both callers, which gate on + // isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own. + return !isCodexAccountUsable(config, accountId, selectionOptions) + || (!isUnknownUsage(usage) && usage >= 100); +} diff --git a/src/codex/routing/cooldown-math.ts b/src/codex/routing/cooldown-math.ts index 123da5e3b16..8b2abe65430 100644 --- a/src/codex/routing/cooldown-math.ts +++ b/src/codex/routing/cooldown-math.ts @@ -5,6 +5,7 @@ import { } from "../quota"; import { isThirtyDayOnlyCodexPlan } from "../plan"; import type { CodexQuotaScope } from "./health-store"; +import type { TransientProbeGrant } from "./thread-affinity"; export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000; export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000; @@ -78,6 +79,15 @@ export type CodexUpstreamOutcomeMeta = { probeLeaseId?: string; /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */ probeQuotaScope?: CodexQuotaScope; + /** + * The half-open TRANSIENT-HOLD probe this request was granted, when it was the one request + * admitted to test a held account (#4701). A different lease to `probeLeaseId` above, in a + * different domain: that one governs a quota cooldown, this one governs a 5xx hold. The two + * are mutually exclusive by construction -- `isTransientOnlyAffinityBlock` refuses to + * recognise a transient hold on an account that carries quota health -- so a request never + * holds both and never pays two recovery permits for one send. + */ + transientProbe?: TransientProbeGrant; /** * Already-chosen alternate for same-request 429 retry. When set, promotion * reuses this account instead of calling {@link pickAlternateCodexAccount} diff --git a/src/codex/routing/selection.ts b/src/codex/routing/selection.ts index 9272636e99f..2720a5eaafc 100644 --- a/src/codex/routing/selection.ts +++ b/src/codex/routing/selection.ts @@ -123,6 +123,39 @@ export function codexAccountBlockReason( return undefined; } +/** + * Drop accounts a confirmed roster says cannot serve this model, unless that leaves nothing. + * + * The restore-on-empty is the whole safety argument, not a defensive afterthought. Roster + * evidence can be wrong in the direction that matters: a shard that has not caught up reports a + * denial for a model the account genuinely owns, and #3022 is what happens when absence is + * allowed to remove a model outright. Because this can only ever return a non-empty subset of a + * list the caller already computed, no pool that would have found a working account can be left + * without one — the worst case is the selection that ships today. + * + * It is an ordering rule rather than an eligibility one for the same reason. Nothing below + * reports `model_not_entitled`, nothing refuses before dispatch, and the existing bounded + * alternate-account retry on an exact unsupported-model 400 stays exactly where it is as the + * safety net. This only stops the pool from CHOOSING an account that has already told us it + * cannot serve the model (#4768). + * + * An operator's manual pin is never dropped. Roster evidence orders the pool's own discretion; + * it does not overrule an explicit human choice, and removing the pinned account here would do + * more than demote it -- `selectPriorityTier` reads the pin to lower the tier ceiling, so a pin + * filtered out beforehand stops acting as a ceiling at all and silently re-enables tiers the + * operator had excluded. An operator who pins an account upstream will refuse still gets the + * alternate-account retry; what they do not get is the pool quietly deciding they were wrong. + */ +export function withoutModelDeniedAccounts( + ids: readonly string[], + denied: ReadonlySet | undefined, + pinned?: string, +): readonly string[] { + if (denied === undefined || ids.length === 0) return ids; + const remaining = ids.filter(id => !denied.has(id) || id === pinned); + return remaining.length > 0 ? remaining : ids; +} + export function getEligiblePoolAccounts( config: OcxConfig, excludeId?: string, @@ -168,11 +201,16 @@ export function getEligiblePoolAccounts( // Single choke point for selection order: every strategy, failover, and preview // reaches the pool through here, so tiering applies once rather than per picker. // Eligibility above is unchanged — this only narrows an already-eligible list. + // + // Model entitlement is applied BEFORE the priority tier, because a tier is a quota-ordering + // question and an account that cannot serve the model at all should not be the reason a tier + // is selected. Both steps narrow an already-eligible list and neither can empty it. + const pinned = pinnedCodexAccountId(config); return selectPriorityTier( - ids, + withoutModelDeniedAccounts(ids, selectionOptions?.deniedModelAccountIds, pinned), codexAccountPriorityLookup(config), id => hasCodexQuotaHeadroom(config, id, selectionOptions, now), - pinnedCodexAccountId(config), + pinned, ); } @@ -563,6 +601,45 @@ export function isUnknownUsage(usage: number): boolean { return usage >= CODEX_UNKNOWN_USAGE_SCORE; } +/** + * Correct a shared cursor that names an account this model's own roster denies (#4768). + * + * {@link getEligiblePoolAccounts} is not the only door into selection. An account that is already + * ACTIVE is served straight from {@link isCodexAccountSelectable} and never passes through the + * eligible list, so ordering that list alone left the exact case the issue reports: once the Free + * account becomes the cursor, every Sol/Astra request keeps going to it and keeps taking the + * upstream unsupported-model 400. {@link pickPriorityPreemption} does not cover it either -- it + * refuses to move toward a tier that does not strictly outrank the active one, which is the usual + * shape here. + * + * Three properties keep this inside "order the already-eligible set" rather than widening it. + * It admits nothing: the replacement comes from {@link getEligiblePoolAccounts}, so every + * eligibility guard has already passed on it. It cannot fail: with no entitled alternative the + * active account is returned unchanged, so this can never turn a served request into `none`. + * And it changes nothing without evidence: absent `deniedModelAccountIds`, or an active account + * nobody denied, it is the identity function. + * + * The caller must NOT persist the result. This is one request's correction for one model, in the + * same spirit as a model detour; the operator's cursor is theirs. A pinned active account is + * exempt outright, for the reason {@link withoutModelDeniedAccounts} gives. + */ +export function preferModelEntitledAccount( + config: OcxConfig, + active: string, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string { + const denied = selectionOptions?.deniedModelAccountIds; + if (denied === undefined || !denied.has(active)) return active; + if (pinnedCodexAccountId(config) === active) return active; + // The eligible list restores denied members when filtering would empty it, so re-filter here: + // moving from one denied account to another buys nothing and costs the warm prefix. + const entitled = getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions) + .filter(id => !denied.has(id)); + return pickLowestUsageAmong(config, entitled, selectionOptions, now) ?? active; +} + /** * Move an unbound request back up when a higher tier regains headroom — the * weekly-reset case. Returns null when nothing should change. diff --git a/src/codex/routing/thread-affinity.ts b/src/codex/routing/thread-affinity.ts index d8d2f5cbfb7..7557ac6537d 100644 --- a/src/codex/routing/thread-affinity.ts +++ b/src/codex/routing/thread-affinity.ts @@ -4,6 +4,8 @@ import { retainedUtf8Bytes } from "../../lib/admission"; import { clearAllCodexPoolRefreshFailures } from "../pool-refresh-backoff"; import type { CodexThreadLineage } from "../lineage"; import type { CodexQuotaScope } from "./health-store"; +import type { TransientProbeLease } from "../../routing/probe-lease"; +import { clearPoolRecoveryState } from "../../routing/probe-lease"; export type ThreadAffinityEntry = { accountId: string; @@ -25,10 +27,51 @@ export type ThreadAffinityEntry = { transientDetourAccountId?: string; }; +/** + * The half-open trial this request was granted against its own held account (#4701). + * + * The lease alone is not enough to settle safely. Its generation is an account-local PROBE + * epoch, while {@link ThreadAffinityEntry.generation} is the selected CREDENTIAL generation, + * and the two move independently: a credential replaced while the probe is in flight leaves + * the probe epoch untouched, so a settle that checked only the lease would write an answer + * about a credential that no longer exists. Capturing the affinity generation here is what + * lets the settle refuse that case. + */ +export interface TransientProbeGrant { + readonly lease: TransientProbeLease; + /** Credential generation the binding held when the probe was granted. */ + readonly affinityGeneration: number; +} + export type CodexThreadResolution = - | { status: "selected"; accountId: string; affinity?: CodexAffinityDecision } + | { + status: "selected"; + accountId: string; + affinity?: CodexAffinityDecision; + /** + * Present only when this request is the single admitted probe of a held account. The + * holder owes the lease a settle or a release; nothing else may act on it. + */ + transientProbe?: TransientProbeGrant; + } | { status: "none"; affinity?: CodexAffinityDecision } - | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision }; + | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision } + /** + * Every candidate for this binding is held and no detour is left, so there is no account + * this request may be sent to. Distinct from `none`: the binding is REMEMBERED and the + * caller is told when to come back, rather than being handed the account already known to + * be failing. Returning `selected` here is the "must not send, sends anyway" defect + * (#4701); the caller must refuse before any upstream I/O. + */ + | { + status: "withheld"; + accountId: string; + /** Earliest moment a recovery dispatch could be admitted. Always strictly in the future. */ + retryAt: number; + /** The remembered detour, when one exists but is itself unusable right now. */ + detourAccountId?: string; + affinity?: CodexAffinityDecision; + }; /** What happened to this thread's binding on this request (#4546). */ export type CodexAffinityMove = @@ -163,6 +206,11 @@ export function clearThreadAccountMap(): void { // A refresh cooldown is per-account runtime state learned alongside these bindings. Leaving it // behind here keeps an account out of selection after the roster it belonged to is gone. clearAllCodexPoolRefreshFailures(); + // Same argument for recovery state (#4701): probe pacing is keyed on account ids this reset + // may have just retired, and the recovery window counts sends made by the roster that is + // going away. A held account nobody may probe because of a lease issued against the previous + // roster is a recovery that never starts. + clearPoolRecoveryState(); conversationStateIssuerMap.clear(); } diff --git a/src/codex/routing/transient-hold-dispatch.ts b/src/codex/routing/transient-hold-dispatch.ts new file mode 100644 index 00000000000..55ce0020828 --- /dev/null +++ b/src/codex/routing/transient-hold-dispatch.ts @@ -0,0 +1,141 @@ +import { isCodexAccountGenerationLive } from "../account-store"; +// From `../account-id`, which declares the constant and imports nothing, rather than from +// `../main-account`, which re-exports it and sits inside the routing/account-lifecycle import +// cycle. Neither reference here runs at module load, but a leaf import keeps this module out of +// that cycle entirely instead of relying on that staying true. +import { MAIN_CODEX_ACCOUNT_ID } from "../account-id"; +import { + invalidateTransientProbe, + releaseTransientProbe, + resolveHeldAccountDispatch, + settleTransientProbe, +} from "../../routing/probe-lease"; +import { + CODEX_TRANSIENT_AFFINITY_HOLD_MS, + type CodexThreadResolution, + type ThreadAffinityEntry, + type TransientProbeGrant, +} from "./thread-affinity"; +import type { CodexUpstreamOutcomeClass, CodexUpstreamOutcomeMeta } from "./cooldown-math"; + +/** + * What a request bound to a HELD account may actually do, and how its trial ends (#4701). + * + * The transient hold (#4546) keeps a thread's binding while its own account serves a 5xx + * streak and detours the request to a healthy sibling. Both of the selector's hold branches + * used to end the same way when no sibling could take it: they returned the held account as + * `selected`, and the caller sent at an account already known to be failing. Under a + * provider-wide 503 that is every bound request at once -- the amplification the hold exists + * to prevent rather than cause. + * + * This module is the seam between the selector and the bounded answer in + * `src/routing/probe-lease.ts`. It is separate from `./probe-lease` in this same directory, + * which is the unrelated QUOTA-COOLDOWN lease; the two govern different domains and must never + * settle each other's probe. + */ + +/** Has a held binding waited longer than a transient failure can reasonably explain? */ +export function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolean { + return entry.transientHoldSince !== undefined + && now - entry.transientHoldSince > CODEX_TRANSIENT_AFFINITY_HOLD_MS; +} + +/** + * Where a request bound to a held account goes this turn. + * + * {@link resolveHeldAccountDispatch} bounds the answer: one probe tests the held account, and a + * caller with nowhere else to go is WITHHELD and told when to come back rather than sent at the + * failure. + * + * A usable detour is taken BEFORE that resolver is consulted, which inverts its own probe-first + * ordering. Deliberately: a healthy sibling is always a better answer for a live request than an + * account carrying a failure streak, and turning the first request after a hold into the trial + * would spend a real user's turn on it. The ordering is not what #4701 bounds -- the defect is + * the third answer the selector used to give, "send at the failing account anyway", and that is + * reached only when no detour exists. Recovery is still discovered there, because that is + * exactly the case where nothing else can find out. + * + * The caller has already committed `transientHoldSince`/`lastUsedAt`; this decides only where + * the request goes. A withheld answer deliberately leaves `transientDetourAccountId` alone: + * being unable to send right now says nothing about which sibling was serving this thread. + */ +export function resolveTransientHoldDispatch( + entry: ThreadAffinityEntry, + detour: string | null, + now: number, +): CodexThreadResolution { + if (detour !== null && detour !== entry.accountId) { + entry.transientDetourAccountId = detour; + // Deliberately no promotion and no rebind: this is one request routing around a blip, not + // the pool deciding where the conversation now lives. + return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } }; + } + const dispatch = resolveHeldAccountDispatch({ boundAccountId: entry.accountId, now }); + if (dispatch.kind === "probe") { + return { + status: "selected", + accountId: entry.accountId, + affinity: { move: "held", reason: "transient" }, + // The credential generation travels with the lease so a settle can refuse an answer about + // a credential this binding no longer has. See {@link TransientProbeGrant}. + transientProbe: { lease: dispatch.lease, affinityGeneration: entry.generation }, + }; + } + if (dispatch.kind === "withheld") { + return { + status: "withheld", + accountId: dispatch.boundAccountId, + retryAt: dispatch.retryAt, + // The remembered sibling, when there is one. It is unusable right now -- that is why this + // request is refused -- but it is what has been serving this thread, and a refusal that + // dropped it would make the next resolve re-pick cold. + ...(entry.transientDetourAccountId !== undefined + ? { detourAccountId: entry.transientDetourAccountId } + : {}), + affinity: { move: "held", reason: "transient" }, + }; + } + // Unreachable: no detour was handed in, so the resolver has none to hand back. Kept total + // rather than cast away, because the cost of being wrong here is a send at a failing account. + entry.transientDetourAccountId = dispatch.accountId; + return { status: "selected", accountId: dispatch.accountId, affinity: { move: "detour", reason: "transient" } }; +} + +/** Does the credential a probe was granted against still exist at that generation? */ +function transientProbeCredentialLive(accountId: string, generation: number): boolean { + if (accountId === MAIN_CODEX_ACCOUNT_ID) return generation === 0; + return isCodexAccountGenerationLive(accountId, generation); +} + +/** + * Conclude the half-open recovery probe this request was holding. + * + * Three answers, because three things can be true of a probe that just ended: + * + * - The credential moved under it. Its result describes an identity the binding no longer has, + * so the epoch is BURNED instead of settled -- invalidating makes every outstanding lease on + * this account stale at once, which is what stops a late answer from reviving a dead account. + * - The answer says nothing about the account. A 3xx, a 400, or an unclassifiable status is the + * request's problem, not the account's, so the lease is handed back unspent and the next + * request may run a real trial instead of waiting out a recovery nobody observed. + * - Otherwise it is evidence: success means recovered, everything else means still failing. + * + * A no-op when this request held no trial, so the outcome recorder calls it unconditionally. + */ +export function settleTransientProbeForOutcome( + accountId: string, + meta: Pick, + outcomeClass: CodexUpstreamOutcomeClass, +): void { + const grant: TransientProbeGrant | undefined = meta.transientProbe; + if (!grant) return; + if (!transientProbeCredentialLive(accountId, grant.affinityGeneration)) { + invalidateTransientProbe(accountId); + return; + } + if (outcomeClass === "neutral" || outcomeClass === "caller" || outcomeClass === "unknown") { + releaseTransientProbe(grant.lease); + return; + } + settleTransientProbe(grant.lease, outcomeClass === "success" ? "recovered" : "failed", meta.now ?? Date.now()); +} diff --git a/src/codex/warmup.ts b/src/codex/warmup.ts index 7be16980c6d..a8ae8c7af5f 100644 --- a/src/codex/warmup.ts +++ b/src/codex/warmup.ts @@ -44,7 +44,7 @@ async function drainErrorBody(res: Response, signal: AbortSignal): Promise fatalUtf8: true, }); } catch (error) { - if (signal.aborted) { + if (signal.aborted && res.status !== 429) { throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: error, }); diff --git a/src/combos/failover.ts b/src/combos/failover.ts index e60fe83deef..f5715da9baf 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -401,11 +401,15 @@ export function comboFailureCooldownScope( ): ComboFailureCooldownScope { const code = normalizedFailureCode(options?.code); // Request-shape refusals first: an oversized request must not cool a healthy target. + // A native transport can surface a zero-output model overflow as a generic + // upstream_server_error carrying precise context-window prose, so consult the bounded + // message classifier too: that target is healthy, the turn was simply too large for it. if ( status === 413 || REQUEST_SHAPE_FAILURE_CODES.has(code) || isRequestLocalFreePromptCap(status, message, options?.code) || isProviderTargetContextOverflow(status, message, options?.code) + || isDefiniteContextOverflow(status, message) || isRequestLocalTargetIncompatibility(status, message, options?.code) ) return "none"; if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider"; @@ -451,6 +455,74 @@ function isProviderTargetContextOverflow( && /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message); } +/** A status can carry a verdict about the REQUEST; 401/403/429 speak about the credential. */ +const CONTEXT_VERDICT_STATUSES: ReadonlySet = new Set([400, 413, 422]); + +/** + * Phrases a provider emits when the INPUT does not fit this model's context window. Matched + * against the innermost provider message only, so an unrelated refusal that merely quotes one + * of these tokens in a code field cannot authorize a replay. + */ +const DEFINITE_CONTEXT_OVERFLOW_PHRASES = [ + "exceeds the context window", + "exceed the context window", + "context window exceeded", + "context length exceeded", + "maximum context length", + "maximum context window", + "too many tokens", +]; + +/** Wrapper envelopes unwrapped before the leaf message is read. */ +const MAX_CONTEXT_OVERFLOW_ENVELOPES = 4; + +function isDefiniteContextOverflowMessage(text: string): boolean { + const normalized = text.toLowerCase(); + return normalized === "context_length_exceeded" + || DEFINITE_CONTEXT_OVERFLOW_PHRASES.some(phrase => normalized.includes(phrase)); +} + +/** + * Confirm a context overflow from the provider MESSAGE rather than from a code token that + * merely appears somewhere in the envelope. An upstream controls both fields and can emit a + * contradictory pair -- `context_length_exceeded` beside `Unsupported parameter: user` -- and + * that is not evidence the turn is too large for this model. `classifyError` reads the whole + * blob, which is exactly the looseness this must not inherit. + * + * A JSON-shaped body that fails to parse is truncated or corrupt, not prose: `classificationText` + * is capped at 500 characters by `normalizeUpstreamErrorText` before it reaches this function, so + * a long envelope arrives here as a JSON prefix. Reading that prefix as plain text would let an + * arbitrary field that happens to sit in the first 500 bytes authorize a hop, so it fails closed. + * + * Only the exact proxy wrapper is unwrapped, within a fixed envelope budget and 16,384 characters. + */ +function isDefiniteContextOverflow(status: number, message: string): boolean { + if (!CONTEXT_VERDICT_STATUSES.has(status) && status < 500) return false; + if (message.length > 16_384) return false; + let text = message.trim(); + // One pass per unwrapped envelope, plus one for the leaf the last envelope yields. + for (let unwrapped = 0; unwrapped <= MAX_CONTEXT_OVERFLOW_ENVELOPES; unwrapped += 1) { + const providerPrefix = /^Provider error \d{3}:\s*/.exec(text); + if (providerPrefix) text = text.slice(providerPrefix[0].length).trim(); + if (!text.startsWith("{")) return isDefiniteContextOverflowMessage(text); + if (unwrapped === MAX_CONTEXT_OVERFLOW_ENVELOPES) return false; + let payload: unknown; + try { payload = JSON.parse(text); } catch { return false; } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const record = payload as Record; + const response = record.response && typeof record.response === "object" && !Array.isArray(record.response) + ? record.response as Record + : undefined; + const source = [record.error, response?.error, response?.last_error, record.last_error, record] + .find((candidate): candidate is Record => + !!candidate && typeof candidate === "object" && !Array.isArray(candidate) + && typeof (candidate as Record).message === "string"); + if (!source) return false; + text = (source.message as string).trim(); + } + return false; +} + export function comboFailureDecision( status: number, message: string, @@ -458,6 +530,10 @@ export function comboFailureDecision( ): ComboFailureDecision { if (status === 499) return "stop"; if (message.toLowerCase().includes("origin_rejected")) return "stop"; + // Structured form of the same hard refusal. The prose test above misses it when the origin + // reports the code out of band, and every hop rule below -- including the context-overflow + // one -- must stay subordinate to it. + if (normalizedFailureCode(options?.code) === "origin_rejected") return "stop"; // The origin may already be executing this turn (the Codex WebSocket relay sent the create // frame and never saw a response event). Hopping would send the same request to a second // target while the first may still be generating; the honest status goes to the client. @@ -476,6 +552,15 @@ export function comboFailureDecision( // (for example 5059 + invalid_request_prompt_too_long). That is evidence that this // target is too small, not that every later combo target is incapable of serving it. if (isProviderTargetContextOverflow(status, message, options?.code)) return "hop"; + // A definite context-window refusal is target-local inside a heterogeneous combo: this model + // cannot hold the turn, but a later target may have a larger window. Two boundaries keep this + // safe. It is reached only after cancellation, structured origin/cyber refusals and + // non-replayable post-send codes have already stopped. And it only ever classifies a failure + // the combo stream preflight already proved emitted no output: `comboStreamPayloadCommitsOutput` + // commits the child on any text, tool call or unknown event, and only a zero-output terminal + // becomes a failure response at all, so a turn whose text the client already saw is never + // reclassified here. + if (isDefiniteContextOverflow(status, message)) return "hop"; // A local input-admission refusal (#1524) says "this candidate cannot fit the request", // not "the request is impossible": the next candidate may have a larger context window. // diff --git a/src/combos/request.ts b/src/combos/request.ts index 63c5ba7fca3..e0fa6426087 100644 --- a/src/combos/request.ts +++ b/src/combos/request.ts @@ -1,5 +1,5 @@ -import type { OcxComboDefaultEffort, OcxComboReasoningEffortMode, OcxComboTarget, OcxConfig } from "../types"; -import { resolveEffortAtOrBelow } from "../reasoning-effort"; +import type { OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboTarget, OcxConfig } from "../types"; +import { isCodexReasoningEffort, resolveEffortAtOrBelow } from "../reasoning-effort"; import { resolveComboId } from "./types"; const warnedUnsupportedDefaults = new Set(); @@ -60,22 +60,29 @@ export function concreteComboRequestBody( defaultEffort: OcxComboDefaultEffort | null, targetReasoningEfforts: readonly string[] | undefined, reasoningEffortMode: OcxComboReasoningEffortMode = "strict", + defaultEffortMode: OcxComboDefaultEffortMode = "fallback", ): Record { const clone = structuredClone(body) as Record; clone.model = `${target.provider}/${target.model}`; + if (defaultEffortMode === "force" && (!defaultEffort || !isCodexReasoningEffort(defaultEffort))) { + throw new Error("force combo default effort requires a valid defaultEffort"); + } if (targetReasoningEfforts?.length === 0 || (reasoningEffortMode === "adaptive" && targetReasoningEfforts === undefined)) { stripUnsupportedReasoningControls(clone); } - if (!defaultEffort) return clone; + if (!defaultEffort || !isCodexReasoningEffort(defaultEffort)) return clone; const reasoning = clone.reasoning; - const needsDefault = reasoning === undefined || ( - reasoning - && typeof reasoning === "object" - && !Array.isArray(reasoning) - && !Object.prototype.hasOwnProperty.call(reasoning, "effort") - ); - if (!needsDefault) return clone; + const reasoningRecord = reasoning && typeof reasoning === "object" && !Array.isArray(reasoning) + ? reasoning as Record + : undefined; + const hasEffort = reasoningRecord !== undefined + && Object.prototype.hasOwnProperty.call(reasoningRecord, "effort"); + const callerEffort = reasoningRecord?.effort; + const validCallerEffort = typeof callerEffort === "string" && isCodexReasoningEffort(callerEffort); + const needsDefault = reasoning === undefined || (reasoningRecord !== undefined && !hasEffort); + const shouldForce = defaultEffortMode === "force" && validCallerEffort; + if (!needsDefault && !shouldForce) return clone; // Picker availability treats an unknown ladder as a wildcard, but runtime // injection stays fail-closed until this concrete target advertises support. // diff --git a/src/combos/types.ts b/src/combos/types.ts index b5c5bf697ce..f4b3e26b2fa 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -1,6 +1,6 @@ import { isCodexReasoningEffort } from "../reasoning-effort"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; -import type { OcxComboConfig, OcxComboDefaultEffort, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types"; +import type { OcxComboConfig, OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types"; import { COMBO_NAMESPACE, isValidComboId, targetKey } from "./identifiers"; export const COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS = 0; @@ -26,6 +26,8 @@ export interface NormalizedComboConfig { cooldownMs?: number; waitForCooldownMs: number; defaultEffort: OcxComboDefaultEffort | null; + /** Client-precedence policy; `fallback` preserves legacy behavior. */ + defaultEffortMode: OcxComboDefaultEffortMode; /** Picker-ladder derivation policy; `strict` preserves the legacy intersection rule. */ reasoningEffortMode: OcxComboReasoningEffortMode; /** Disable image input; `auto` preserves the intersection derived from all targets. */ @@ -167,6 +169,21 @@ export function comboConfigIssues( message: "defaultEffort must be one of: low, medium, high, xhigh, max, ultra", }); } + if (body.defaultEffortMode !== undefined + && body.defaultEffortMode !== "fallback" + && body.defaultEffortMode !== "force") { + issues.push({ + path: ["defaultEffortMode"], + message: 'defaultEffortMode must be "fallback" or "force"', + }); + } + if (body.defaultEffortMode === "force" + && (typeof body.defaultEffort !== "string" || !isCodexReasoningEffort(body.defaultEffort))) { + issues.push({ + path: ["defaultEffort"], + message: "defaultEffort is required when defaultEffortMode is force", + }); + } if (body.imageInput !== undefined && body.imageInput !== "auto" && body.imageInput !== "disabled") { issues.push({ path: ["imageInput"], message: 'imageInput must be "auto" or "disabled"' }); } @@ -293,12 +310,16 @@ export function comboConfigError( export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig { const alias = typeof raw.alias === "string" ? raw.alias.trim() : ""; const displayName = typeof raw.displayName === "string" ? raw.displayName.trim() : ""; + const defaultEffort = typeof raw.defaultEffort === "string" && isCodexReasoningEffort(raw.defaultEffort) + ? raw.defaultEffort + : null; return { strategy: raw.strategy ?? "failover", stickyLimit: raw.stickyLimit ?? 1, cooldownMs: raw.cooldownMs, waitForCooldownMs: raw.waitForCooldownMs ?? COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS, - defaultEffort: raw.defaultEffort ?? null, + defaultEffort, + defaultEffortMode: raw.defaultEffortMode === "force" && defaultEffort !== null ? "force" : "fallback", reasoningEffortMode: raw.reasoningEffortMode === "adaptive" ? "adaptive" : "strict", imageInput: raw.imageInput === "disabled" ? "disabled" : "auto", alias: alias || null, diff --git a/src/config/pending-teardown.ts b/src/config/pending-teardown.ts index bfab1cf7575..9b1f5e97a2f 100644 --- a/src/config/pending-teardown.ts +++ b/src/config/pending-teardown.ts @@ -202,6 +202,37 @@ export function pendingTeardownOutstanding(): boolean { } } +/** + * Are the outstanding obligations EXACTLY the ones this stop chose to keep? + * + * `ocx stop` can preserve its own obligations deliberately — the Codex history preflight + * refuses before anything is restored, so the receipt has to survive for a later stop + * (#4718). That is safe for an update to continue past, because the stop knows those + * receipts describe a proxy it just proved down. + * + * Nothing else is. A quarantined receipt is waiting on a human, and a receipt belonging + * to a live owner means another stop is in flight; letting either ride along would turn + * "we deliberately kept ours" into "we ignored everyone's". So membership is the test, + * not a count of ours: an unrecognized obligation of any kind answers false and the + * caller falls back to the ordinary failure code. + * + * Quarantined names are included in the scan on purpose. They do not correspond to any + * nonce this run preserved, so their presence always answers false. + */ +export function pendingTeardownsAreExactly(nonces: readonly string[]): boolean { + const expected = new Set(nonces.map(nonce => `${PREFIX}${nonce}${SUFFIX}`)); + let names: string[]; + try { + names = readdirSync(getConfigDir()); + } catch (error) { + // A home that does not exist holds nothing, which matches only an empty expectation. + // Any other scan failure may be hiding an obligation and must not answer "exactly". + return (error as NodeJS.ErrnoException).code === "ENOENT" && expected.size === 0; + } + const found = names.filter(isAnyTeardownObligationFileName); + return found.length === expected.size && found.every(name => expected.has(name)); +} + /** Paths of quarantined obligations awaiting a human. */ export function listQuarantinedTeardowns(): string[] { try { diff --git a/src/images/loop.ts b/src/images/loop.ts index 6f9eacad1fd..193bbef45f9 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -615,7 +615,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise = new Set([ let logicalRequestSeq = 0; -export function createRequestExecutionBudget( - policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY, - logicalRequestId?: string, +/** + * One request's physical-send ledger, held apart from the budget object so a derived policy + * scope can share the exact same one. + * + * `spent` and `pendingExternalSends` belong together: a pending booking is a send that is + * already counted in `spent` and awaiting its reporter, so a scope that shared one without the + * other would either charge that send twice or never charge it at all. + * + * The durable-spend observer belongs here for the same reason. It books one entry per physical + * send by watching this counter move, so a derived scope that spent the counter without + * carrying the observer would move it without booking, and a combo child's sends would go + * missing from the ledger (#4707). + */ +interface SharedSendLedger { + spent: number; + pendingExternalSends: number; + readonly observer?: RequestSendObserver; +} + +const sharedSendLedgers = new WeakMap(); + +function createRequestExecutionBudgetWithLedger( + policy: RequestExecutionBudgetPolicy, + logicalRequestId: string | undefined, + counter: SharedSendLedger, ): RequestExecutionBudget { - let spent = 0; - // Reservations whose physical send is reported by a retry helper rather than by the permit. - // They are already charged; the reporter's first send settles one instead of charging again. - let pendingExternalSends = 0; + const observer = counter.observer; let reserveSpent = false; let alternateTargetSends = 0; let targetTransitions = 0; let lastTargetKey: string | undefined; const budget: RequestExecutionBudget = { - get used(): number { return spent; }, + get used(): number { return counter.spent; }, set used(next: number) { // The retry helpers report their real send count by assigning through this field. A // reservation taken with `countedExternally` has already booked one of those sends, so // the report settles the pending booking first and only the surplus is charged. - const delta = next - spent; + const delta = next - counter.spent; if (delta <= 0) { - spent = Math.max(0, next); + counter.spent = Math.max(0, next); return; } - const settled = Math.min(delta, pendingExternalSends); - pendingExternalSends -= settled; - spent += delta - settled; + const settled = Math.min(delta, counter.pendingExternalSends); + counter.pendingExternalSends -= settled; + const charged = delta - settled; + counter.spent += charged; + // These sends have already left. The ledger records them even past a ceiling it would + // have refused, because refusing after the fact only hides spend that was really + // incurred -- the refusal has to happen at the reservation below, or not at all. + for (let index = 0; index < charged; index += 1) observer?.charge(); }, logicalRequestId: logicalRequestId ?? `lr-${Date.now().toString(36)}-${(logicalRequestSeq += 1).toString(36)}`, policyVersion: REQUEST_BUDGET_POLICY_VERSION, @@ -171,11 +229,11 @@ export function createRequestExecutionBudget( get lastTargetKey() { return lastTargetKey; }, remainingBaseSends(cap: number): number { const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0; - return Math.max(0, Math.min(capped, policy.baseSendAllowance - spent)); + return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent)); }, reserveDispatch(intent: DispatchIntent): DispatchDecision { if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" }; - if (spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" }; + if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" }; const changesTarget = lastTargetKey !== undefined && lastTargetKey !== intent.targetKey; const isAlternateTarget = changesTarget || intent.sendClass === "account-failover" @@ -190,7 +248,7 @@ export function createRequestExecutionBudget( // The base allowance is spent first. Only once it is gone does a recovery class reach // for the single shared reserve -- an account move and a validated rebuild cannot each // take one. - const drawsReserve = policy.baseSendAllowance - spent <= 0; + const drawsReserve = policy.baseSendAllowance - counter.spent <= 0; if (drawsReserve) { if (!RESERVE_FUNDED_CLASSES.has(intent.sendClass)) { return { allowed: false, reason: "base-allowance-exhausted" }; @@ -200,13 +258,18 @@ export function createRequestExecutionBudget( } } + // Consulted last, because it is the only bound here that WRITES. A ledger entry booked + // for a dispatch a cheaper check above would have refused is spend this request never + // makes, and it would hold those tokens against the scope until retention expired. + if (observer && !observer.charge()) return { allowed: false, reason: "spend-exhausted" }; + // THE RESERVATION IS THE CHARGE. Deciding here and charging in `use()` left a window in // which two legs read the same remainder, both received a permit, and both dispatched: // one remaining send admitted two physical sends, which is the per-request multiplication // this budget exists to stop. Everything is booked now; `release()` is the way back. const previousTargetKey = lastTargetKey; - spent += 1; - if (intent.countedExternally === true) pendingExternalSends += 1; + counter.spent += 1; + if (intent.countedExternally === true) counter.pendingExternalSends += 1; if (drawsReserve) reserveSpent = true; if (isAlternateTarget) alternateTargetSends += 1; if (changesTarget) targetTransitions += 1; @@ -222,16 +285,28 @@ export function createRequestExecutionBudget( settled = "used"; return true; }, + assumeCharge(): boolean { + if (settled !== "open") return false; + settled = "used"; + // The booking this reservation made for an external reporter is now owned by the + // caller. Leaving it pending is not harmless: the next `used` report of this request + // would settle against it and one real send would go uncharged. + if (intent.countedExternally === true && counter.pendingExternalSends > 0) { + counter.pendingExternalSends -= 1; + } + return true; + }, release(): void { if (settled !== "open") return; settled = "released"; // An externally counted reservation the reporter already settled paid for a send // that physically happened. Refunding it would hand the request a free send back. if (intent.countedExternally === true) { - if (pendingExternalSends === 0) return; - pendingExternalSends -= 1; + if (counter.pendingExternalSends === 0) return; + counter.pendingExternalSends -= 1; } - spent -= 1; + counter.spent -= 1; + observer?.refund(); if (drawsReserve) reserveSpent = false; if (isAlternateTarget) alternateTargetSends -= 1; if (changesTarget) targetTransitions -= 1; @@ -241,9 +316,60 @@ export function createRequestExecutionBudget( }; }, }; + sharedSendLedgers.set(budget, counter); return budget; } +export function createRequestExecutionBudget( + policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY, + logicalRequestId?: string, + observer?: RequestSendObserver, +): RequestExecutionBudget { + return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, { + spent: 0, + pendingExternalSends: 0, + ...(observer ? { observer } : {}), + }); +} + +/** + * A budget that applies its own policy and keeps its own recovery ledgers while spending the + * parent's exact physical-send ledger. + * + * Aliasing the public `used` property was not enough, and that is the whole defect. The factory + * reads its own private counter back in `remainingBaseSends`, in the total check, and in the + * reserve test, so an aliased scope answered every admission question from a counter that only + * ever saw its own reservations. A combo's per-target holdback is computed from + * `maxTotalModelSends` and is therefore unenforceable unless the scope actually observes what + * the request has already spent. + */ +export function deriveRequestExecutionBudget( + parent: RequestExecutionBudget, + policy: RequestExecutionBudgetPolicy, +): RequestExecutionBudget { + return createRequestExecutionBudgetWithLedger(policy, parent.logicalRequestId, ledgerFor(parent)); +} + +/** + * A budget that did not come from this factory still honors the public `used` contract, so + * bridge onto it rather than failing the request. `isRequestExecutionBudget` is a shape test, + * so a stub can reach here; turning that into a thrown error would convert a routing request + * into a 500 to report a condition production never produces. Only a factory-backed parent can + * share pending external bookings and a durable-spend observer, which are private by + * construction; a bridged scope keeps the parent's spend accurate and books nothing of its own. + */ +function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger { + const existing = sharedSendLedgers.get(parent); + if (existing) return existing; + let pendingExternalSends = 0; + return { + get spent(): number { return parent.used; }, + set spent(next: number) { parent.used = next; }, + get pendingExternalSends(): number { return pendingExternalSends; }, + set pendingExternalSends(next: number) { pendingExternalSends = next; }, + }; +} + export function isRequestExecutionBudget( value: TransientSendBudget | undefined, ): value is RequestExecutionBudget { diff --git a/src/lib/spend-reservation-ledger.ts b/src/lib/spend-reservation-ledger.ts index 21cdd78c721..de7dba4e05b 100644 --- a/src/lib/spend-reservation-ledger.ts +++ b/src/lib/spend-reservation-ledger.ts @@ -669,6 +669,24 @@ export function createSpendReservationLedger(options: { case "checkpoint": applyCheckpoint(record); break; } } + // A reservation that survived replay has no owner left. The process that made it is gone, + // so nothing in this one can ever settle it, and leaving it live means the send stays + // pending forever against a scope that can never resolve it. Deleting the entry is not the + // alternative either: that would hand the same send id a second reservation. + // + // Both live states resolve to UNRESOLVED, including an undispatched one. The tempting + // distinction -- open never reached the wire, so give its tokens back -- assumes the + // journal is complete up to the crash, and the torn-tail handling above says it is not: a + // send can dispatch and die before its dispatch record lands. Abandoning that reservation + // returns tokens for a send that may have been billed, and worse, it RESETS a ceiling that + // had already fired. An exhausted scope staying exhausted across a restart is the whole + // reason this store is on disk. + const reconciledAt = now(); + for (const [send, reservation] of reservations) { + if (!isLive(reservation.status)) continue; + applyResolve(send, "lost", 0, reconciledAt); + append({ v: 1, kind: "lost", send, at: reconciledAt }); + } } /** diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 13a22bfce08..849f145848c 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -21,7 +21,7 @@ import { } from "../combos/failover"; import { reconcileComboWarningMemos } from "../combos/request"; import { reconcileComboRotationState } from "../combos/resolve"; -import { reconcileComboRecall } from "../server/responses/combo-session-recall"; +import { reconcileComboRecall, sweepExpiredComboRecall } from "../server/responses/combo-session-recall"; import { listLiveComboTargetKeys } from "../combos/types"; import { listLiveConfigOwnershipRoots, @@ -112,7 +112,11 @@ export const STATE_STORE_REGISTRATIONS = [ { name: "model-cache-history", reconcileGeneration: reconcileModelCacheGeneration }, { name: "pool-rotation", reconcileGeneration: reconcilePoolRotationState }, { name: "combo-rotation", reconcileGeneration: reconcileComboRotationState }, - { name: "combo-session-recall", reconcileGeneration: reconcileComboRecall }, + { + name: "combo-session-recall", + sweepExpired: sweepExpiredComboRecall, + reconcileGeneration: reconcileComboRecall, + }, { name: "guardian-backoff", reconcileGeneration: reconcileGuardianBackoff }, { name: "codex-reauth", reconcileGeneration: reconcileCodexReauthState }, { name: "oauth-reauth", reconcileGeneration: reconcileOAuthReauthState }, diff --git a/src/lib/test-home-guard.ts b/src/lib/test-home-guard.ts index 0c1a0ad715e..63537742d01 100644 --- a/src/lib/test-home-guard.ts +++ b/src/lib/test-home-guard.ts @@ -20,7 +20,7 @@ * how this incident happened. */ import { homedir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { realpathSync } from "node:fs"; const GUARD_ENV = "OCX_TEST_HOME_GUARD"; @@ -152,3 +152,87 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void { + "Point CODEX_HOME at a temp directory for this test before writing native auth.json.", ); } + +/** + * The trees a removal must never reach, and the reason each one is named. + * + * The writer guard above cannot help here. `rmSync` is plain `node:fs`: it calls no writer of + * ours, so no assertion of ours runs, and by the time anything could observe the damage the + * directory is already gone. On 2026-09-15 that is exactly what happened — a test resolved the + * process-global config directory and removed it, taking every OAuth login, the Codex account + * store, the service tokens and a 372MB usage ledger with it. + */ +const PROTECTED_TREES: ReadonlyArray<{ path: string; lexical: string; label: string }> = [ + { path: PROTECTED_HOME, lexical: resolve(join(REAL_HOME, ".opencodex")), label: "the real OpenCodex home" }, + { path: PROTECTED_CODEX_HOME, lexical: resolve(join(REAL_HOME, ".codex")), label: "the real Codex home" }, + { + path: PROTECTED_LAUNCH_AGENTS, + lexical: resolve(join(REAL_HOME, "Library", "LaunchAgents")), + label: "the real LaunchAgents directory", + }, +]; +const PROTECTED_REAL_HOME = canonicalize(REAL_HOME); +const LEXICAL_REAL_HOME = resolve(REAL_HOME); + +/** Canonical paths whose removal is refused. Exported so the guard's tests cannot drift off them. */ +export function protectedRemovalTreesForTests(): readonly string[] { + return [PROTECTED_REAL_HOME, ...PROTECTED_TREES.map(tree => tree.path)]; +} + +/** Whether `child` sits strictly below `parent`, both already canonicalized. */ +function isInside(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); +} + +/** + * Why removing `target` is refused, or `null` when it is not a protected location. + * + * Three relations are refused, not one. Equality alone would still permit + * `rmSync(getConfigPath())` against a live `config.json`, and it would permit + * `rmSync(homedir())`, which takes the protected tree with it. So a target is refused when it + * IS a protected tree, when it sits INSIDE one, or when it is an ANCESTOR of one. + * + * Canonicalization is what makes a symlink useless as a bypass: a temp path that merely points + * at the real home resolves to the real home before any comparison happens. + */ +export function protectedRemovalReason(target: string): string | null { + // Both spellings are judged, not just the canonical one. Canonicalization is what defeats a + // symlink alias, but it also resolves the target away: if `~/.opencodex` is itself a link, + // the literal path a caller passed is the thing that gets unlinked, and only the lexical + // form still names it. Upstream Codex makes the same distinction in its writable-root + // handling, keeping logical and resolved forms side by side rather than collapsing to one. + for (const candidate of [canonicalize(target), resolve(target)]) { + if (candidate === PROTECTED_REAL_HOME || candidate === LEXICAL_REAL_HOME) { + return `the real home directory (${PROTECTED_REAL_HOME})`; + } + for (const tree of PROTECTED_TREES) { + for (const protectedPath of [tree.path, tree.lexical]) { + if (candidate === protectedPath) return `${tree.label} (${protectedPath})`; + if (isInside(protectedPath, candidate)) return `a path inside ${tree.label} (${protectedPath})`; + if (isInside(candidate, protectedPath)) return `an ancestor of ${tree.label} (${protectedPath})`; + } + } + } + return null; +} + +/** + * Throw before a removal that would reach a protected tree. + * + * Deliberately NOT gated on {@link isTestHomeGuardArmed}. Arming happens in `tests/preload.ts`, + * which Bun loads from the `bunfig.toml` it finds in the CURRENT WORKING DIRECTORY — so a run + * started outside the repository arms nothing, leaves OPENCODEX_HOME unset, and resolves the + * developer's real home. That unarmed run is precisely the one that caused the incident, so the + * refusal has to hold without it. Nothing in production calls this; the callers are test + * helpers, where the only cost of an unconditional check is a path comparison. + */ +export function assertRemovalOutsideProtectedTrees(target: string): void { + const reason = protectedRemovalReason(target); + if (reason === null) return; + throw new Error( + `refusing to remove ${reason} from a test process: "${target}" resolves there. ` + + "Create the directory this test owns with createTempHome() from tests/helpers/temp-home " + + "and remove that handle instead (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).", + ); +} diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 3a4fb619a37..8dd6f1146f1 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -2,10 +2,10 @@ * Retry guard for upstream fetches that die on stale pooled keep-alive sockets. * * chatgpt.com (Cloudflare) closes idle keep-alive connections server-side; Bun's fetch pool - * reuses the half-closed socket and the request write fails with ECONNRESET before any - * response bytes arrive. Retrying on a fresh connection is safe for our replayable - * (string-body) upstream requests, because fetch() rejects only before response headers — - * a caught error here means no response was ever received. + * reuses the half-closed socket and a request can fail before response headers arrive. + * A pre-header rejection does not prove that the origin did not process the request. + * Mechanically reusable bytes do not make a model POST idempotent: an ambiguous reset + * becomes a terminal, non-replayable response unless the operation is explicitly safe. * * Deliberately narrow: timeouts, aborts, ECONNREFUSED/DNS/TLS failures, and HTTP error * statuses (returned as Response, never thrown) are NOT retried. Mid-stream SSE resets are @@ -36,19 +36,66 @@ export function isNonReplayableResponse(response: Response): boolean { return nonReplayableResponses.has(response); } +/** + * The narrower marker: responses this proxy synthesized as a replay refusal. + * + * {@link isNonReplayableResponse} answers "must not be sent again", which the WebSocket + * post-send verdicts share. This one answers "the upstream never said this", and that is the + * question a quota recorder or a `Retry-After` synthesizer has to ask. Both were written for + * a status that only ever arrived from a provider, so a synthetic 429 reads to them as a + * credential that rate-limited us and as a wait worth honouring -- one writes a cooldown + * against a credential that refused nothing, the other instructs the client to send the turn + * again. A marker rather than a body check, because it has to be answerable before the body + * is read and cannot be spoofed by an upstream that happens to echo the code. + */ +const replayRefusalResponses = new WeakSet(); + +export function markReplayRefusalResponse(response: Response): void { + replayRefusalResponses.add(response); +} + +export function isReplayRefusalResponse(response: Response): boolean { + return replayRefusalResponses.has(response); +} + /** Origin never produced a response event; the turn may still be executing. */ export const UPSTREAM_NO_RESPONSE_CODE = "upstream_no_response"; /** Transport closed after the send, before any response event. */ export const UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE = "upstream_closed_before_response"; +/** + * This proxy refused to replay a pre-header fetch rejection. + * + * Distinct from {@link UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE}, which the Codex WebSocket + * transport settles as a 502 after the create frame was already sent. Both are ambiguous, + * but only this one is a refusal this process made before any response existed, so it + * follows the send-budget precedent and answers 429: the Codex client is configured with + * `retry_429: false` and `retry_5xx: true` over four attempts, so a 5xx here multiplies + * the duplicate send the refusal exists to prevent. See + * structure/transports/responses.md#ambiguous-connection-reset-replay-boundary. + */ +export const UPSTREAM_RESET_REPLAY_REFUSED_CODE = "upstream_reset_replay_refused"; const NON_REPLAYABLE_UPSTREAM_CODES: ReadonlySet = new Set([ UPSTREAM_NO_RESPONSE_CODE, UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE, + UPSTREAM_RESET_REPLAY_REFUSED_CODE, ]); export function isNonReplayableUpstreamCode(code: unknown): boolean { return typeof code === "string" && NON_REPLAYABLE_UPSTREAM_CODES.has(code); } +/** + * True for the one non-replayable code this proxy owns end to end. The status it carries is + * a local decision, so a re-wrapping formatter must restate it rather than inherit the + * caller's upstream-shaped status. + */ +export function isReplayRefusalCode(code: unknown): boolean { + return code === UPSTREAM_RESET_REPLAY_REFUSED_CODE; +} + +/** Client-facing status for {@link UPSTREAM_RESET_REPLAY_REFUSED_CODE}. */ +export const REPLAY_REFUSED_STATUS = 429; + // 1 initial + 2 retries: the pool may hold more than one stale socket. const RESET_RETRY_MAX_ATTEMPTS = 3; const RESET_RETRY_BASE_DELAY_MS = 150; @@ -352,6 +399,12 @@ export async function fetchWithAttemptDeadline( } export interface ResetRetryOptions { + /** + * Opt in only when repeating this operation cannot duplicate upstream effects. + * This permits reset retries, not extra sends: attempts and onSendsConsumed still + * bound and count every physical send. A string body is not replay-safety proof. + */ + replaySafe?: boolean; abortSignal?: AbortSignal; /** Short host/path label for the retry warn log (no secrets/query strings). */ label?: string; @@ -442,9 +495,9 @@ export function applyUpstreamRecoveryInit( } /** - * Run `doFetch`, retrying only connection-reset-shaped rejections (see - * isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe - * (string body); every retry is logged so persistent resets stay visible. + * Run `doFetch` within one send budget. Connection-reset-shaped rejections are + * terminal by default; only an explicitly replay-safe operation receives reset retries + * with jittered backoff. HTTP responses retain the caller's existing retry policy. */ export async function fetchWithResetRetry( doFetch: ReplayableFetch, @@ -475,6 +528,20 @@ export async function fetchWithResetRetry( if (sawReset) throw new UpstreamRetryEvidenceError([], err, true); throw err; } + if (opts.replaySafe !== true) { + // Return evidence instead of throwing a generic transport error: outer catches + // otherwise turn it into a replayable 502 and a combo/account recovery resends it. + // The WeakSet protects in-process recovery; the code survives JSON re-wrapping. + // Never expose the raw exception, which can contain credentials or request data. + const response = new Response(JSON.stringify({ error: { + type: "upstream_error", + code: UPSTREAM_RESET_REPLAY_REFUSED_CODE, + message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.", + } }), { status: REPLAY_REFUSED_STATUS, headers: { "content-type": "application/json" } }); + markResponseNonReplayable(response); + markReplayRefusalResponse(response); + return response; + } if (attempt === attempts - 1) throw err; sawReset = true; lastError = err; @@ -491,9 +558,9 @@ export async function fetchWithResetRetry( } /** - * fetchWithResetRetry plus a transient-5xx status retry layer, PRE-STREAM only: a - * returned Response has by definition not been relayed to the client yet, so replaying - * the (string-body) request is safe. The failed attempt's body is cancelled before the + * fetchWithResetRetry plus the caller-selected transient-5xx policy, PRE-STREAM only. + * A received HTTP error follows that policy; an ambiguous reset's non-replayable + * verdict always stops it. The failed attempt's body is cancelled before the * retry; every returned response (ok, non-transient, aborted, slow, exhausted) keeps * its body intact. Honors Retry-After via retryBackoffDelayMs. * diff --git a/src/lib/windows-elevation.ts b/src/lib/windows-elevation.ts index b2d02b81234..171545276cf 100644 --- a/src/lib/windows-elevation.ts +++ b/src/lib/windows-elevation.ts @@ -250,6 +250,21 @@ export const OCX_ELEVATED_PROTOCOL_FAILED = 13; /** Windows ERROR_CANCELLED — reserved for UAC denial; never emitted by the elevated script. */ export const OCX_ELEVATED_UAC_CANCELLED = 1223; +/** + * The elevated process could not read a staged payload (#4692). + * + * `hardenSecretPath` grants the staging account and strips inheritance, so a split-token + * elevation of the same user reads the file and an elevation answered with a DIFFERENT + * administrator's credentials does not. The elevated side cannot explain that itself: it + * runs hidden, so its stderr goes nowhere and only the exit code survives the boundary. + * Without a code of its own the operator would be told "exit code 1" for a cause that + * names its own remedy — the same undiagnosable failure this change set exists to remove. + * + * Deliberately outside OCX_ELEVATED_PROTOCOL_CODES: that list is the create-and-run + * transaction's alphabet, and this code belongs to the registration path. + */ +export const OCX_ELEVATED_STAGING_UNREADABLE = 14; + export const OCX_ELEVATED_PROTOCOL_CODES = [ OCX_ELEVATED_SUCCESS, OCX_ELEVATED_CREATE_FAILED, @@ -645,36 +660,83 @@ export function runWindowsElevated(file: string, args: string[]): Promise